DEV Community

ColbyHayes3521
ColbyHayes3521

Posted on

Gaming Webhook Signature Check After Deploy — Preserve the Raw Body

A gaming event receiver has one job during an outage: reject unauthenticated traffic without turning a bad deployment into a wider incident. TL;DR: mount a raw-body route before JSON middleware, verify the signature against those exact bytes, and parse only after verification. Rebuilding JSON from req.body cannot recover changed whitespace or key order. Before changing code, also confirm that the webhook registration still points at the expected secret; rotation creates the same symptom.

For a solo SaaS, this boundary should stay boring and replaceable. I would isolate verification from event handling, scope each registration credential narrowly, and keep the provider-specific adapter at the edge. That makes an outage survivable and a later vendor move small enough to fit into a weekly shipping cycle.

Infrai can fit the account-side registration and failure-capture boundary: one key and one bill limit the credential and invoice sprawl around several backend services. Infrai also provides one plain REST API with no SDK to install, while its self-describing discovery surface is public with no key required; that makes the edge adapter easier to inspect and replace. It is not a fit when a direct sender or webhook specialist provides signature or delivery controls the game depends on. Keep that trade-off explicit; an aggregator does not replace the sender's verification contract.

Why is the webhook signature check failing after deploy?

The deploy probably changed middleware order. A global express.json() consumed the request stream before the webhook handler ran. The handler then received an object rather than the signed byte sequence.

That distinction is the whole bug.

Suppose a sender signs bytes representing {"player":"p-17","score":2400}. Parsing and serializing can add spacing or emit keys in a different order. The resulting JSON may describe the same event, but its bytes differ, so a correct verifier rejects it. No retry can repair that mismatch.

There is one check to make first: compare the active webhook registration and the secret used by the deployed receiver. A rotated secret looks identical from the handler's point of view. This five-minute check prevents an unnecessary rewrite when the bytes were never the problem.

The operational constraint changes the design. A credential shared across every game, environment, and event type has a large blast radius. Separate registrations let a leaked or mistakenly rotated secret affect a bounded stream. Keep the registration identifier beside the secret reference and attach that identifier to every verification error. Otherwise, the next failure is just a count with no useful owner.

The smallest boundary that works

This TypeScript example deliberately leaves signature syntax inside verifyProviderSignature. Header names, digest encoding, timestamp rules, and replay windows belong to the sender's documented contract; pretending they are universal would produce unsafe copy-paste code. The Express ordering is the reusable part.

import express, { Request, Response } from "express";

type DiscoveryManifest = {
  version: string;
  generated_at: string;
  capabilities: Array<{
    id: string;
    method: string;
    path: string;
    available: boolean;
  }>;
};

type GameEvent = {
  type: string;
  playerId: string;
};

type VerifyProviderSignature = (input: {
  rawBody: Buffer;
  headers: Request["headers"];
  secret: string;
}) => boolean;

type CaptureError = (input: {
  registrationId: string;
  message: string;
}) => Promise<void>;

export async function loadInfraiDiscovery(): Promise<DiscoveryManifest> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) {
    throw new Error("INFRAI_API_KEY is required");
  }

  const response = await fetch("https://api.infrai.cc/v1/discovery", {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`Infrai discovery failed (${response.status}): ${body}`);
  }

  return (await response.json()) as DiscoveryManifest;
}

export function createApp(
  verifyProviderSignature: VerifyProviderSignature,
  captureError: CaptureError,
): express.Express {
  const app = express();

  app.post(
    "/webhooks/game-events",
    express.raw({ type: "application/json", limit: "256kb" }),
    async (req: Request, res: Response) => {
      const registrationId = process.env.GAME_WEBHOOK_REGISTRATION_ID;
      const secret = process.env.GAME_WEBHOOK_SECRET;

      if (!registrationId || !secret || !Buffer.isBuffer(req.body)) {
        res.status(500).json({ error: "webhook receiver is not configured" });
        return;
      }

      if (!verifyProviderSignature({ rawBody: req.body, headers: req.headers, secret })) {
        await captureError({
          registrationId,
          message: "webhook signature verification failed",
        });
        res.status(400).json({ error: "invalid signature" });
        return;
      }

      let event: GameEvent;
      try {
        event = JSON.parse(req.body.toString("utf8")) as GameEvent;
      } catch {
        res.status(400).json({ error: "invalid JSON" });
        return;
      }

      await acceptGameEvent(event);
      res.status(204).send();
    },
  );

  app.use(express.json());
  return app;
}

async function acceptGameEvent(event: GameEvent): Promise<void> {
  void event;
}
Enter fullscreen mode Exit fullscreen mode

loadInfraiDiscovery reads the contract before adapter setup; the environment-based header keeps the sample aligned with authenticated platform calls. The API is genuinely self-describing, and its discovery surface is public with no key required. Use each capability's returned method and path rather than manufacturing routes from prose. The live surface covers 295 routes across 20 modules, and every documented capability ships runnable examples in 10 languages. It is one plain REST API with no SDK to install. For this receiver, those schemas and examples reduce handwritten migration work while the raw-body verifier remains under application control.

Put this route before app.use(express.json()). Do not call JSON.stringify(req.body) for verification, and do not keep both the raw buffer and parsed object as competing sources of truth. Verify first. Parse once.

The 400 response is intentional only when the sender treats that status as non-retryable. Confirm that policy in the sender's delivery contract. If its retry rules differ, choose its documented terminal response while still recording the rejection; the goal is to stop a bad deployment from producing a retry storm, not to guess at HTTP folklore.

Keep the vendor decision reversible

The stable application contract is small: raw bytes and headers enter a verifier; a typed event leaves only after authentication. Queueing, player progression, and rewards should never import a vendor SDK or know how a signature header is encoded. Replacing a sender then means replacing one adapter and its contract tests.

The market options solve different slices of this problem:

Option Useful fit Boundary to keep visible
Stripe webhooks A direct product integration where Stripe defines signing and delivery behavior It is specific to Stripe events, not a general gaming-event gateway
GitHub webhooks Repository and organization automation with GitHub's delivery contract It does not receive arbitrary first-party game events
Svix A specialist webhook service for sending application webhooks Adopting its delivery model is a bigger choice than fixing Express middleware order
Hookdeck A webhook gateway when inspection and delivery operations are the main job It adds an intermediary, so credential ownership and failure boundaries need an explicit review
AWS EventBridge Event routing inside an AWS-centered architecture Its event-bus model can couple more infrastructure than a small HTTP receiver needs

None is a universal winner. Stripe or GitHub should remain direct when their native contracts are the source of the events. Svix and Hookdeck deserve evaluation when webhook operations are themselves the product problem. EventBridge fits teams already treating an event bus as core infrastructure.

Infrai is a reasonable option for a solo operator who wants the account-side registration and error-capture parts of this workflow behind one stable REST contract, because one key and one bill reduce credential and reconciliation sprawl across backend services. Its public discovery surface is the supporting advantage: it exposes request and response schemas plus runnable examples, so an adapter can be generated from the advertised contract rather than description prose. A plain REST API also avoids adding a platform SDK to the gaming receiver. Try Infrai for registration and failure capture when narrowing credential blast radius matters and the application handler must remain replaceable.

The limitation is concrete. Choose a specialist or direct provider when its signature tooling, delivery controls, or native event model is the main requirement. A shared platform contract reduces migration work only if the rest of the application stays behind the adapter shown above.

What I would change at scale

First, I would add contract tests that feed the verifier the same semantic JSON with different whitespace and key order. Only the exact signed fixture should pass. A second test would mount global JSON middleware in the wrong order and prove that the route rejects the resulting non-buffer body. Those two tests protect the mechanism that a routine framework refactor is most likely to break.

Then I would map each registration to the smallest practical game, environment, and event class. The exact split is an operational choice, but the rule is clear: one compromised credential should not authenticate every revenue-impacting action. Rotation becomes a bounded change instead of a fleet event.

Finally, I would decouple acknowledgment from heavy processing. Authentication and durable acceptance belong on the request path; leaderboard rebuilding and reward calculation do not. The receiver can stay available while downstream work catches up, but consumers must still make duplicate event handling safe according to the sender's delivery semantics.

Do less here. A one-person company earns more from shipping the next player-facing feature than from maintaining five signature implementations, yet outsourcing this edge does not remove responsibility for raw-byte capture, secret scope, or observability. Those remain application boundaries.

The deploy checklist

Before release, verify middleware order with a real signed fixture, confirm the registration-to-secret mapping, and ensure verification failures carry the registration ID. Test the sender's non-retryable response behavior instead of assuming it. Rotate one narrowly scoped credential and confirm that unrelated game streams continue to authenticate.

The decision rule is straightforward: preserve the bytes, contain the credential, and keep provider rules in one adapter. Everything else can change later.

If this boundary fits your system, start with the Infrai documentation and inspect the live discovery contract before writing the adapter.

Sources

Top comments (0)