> ## Documentation Index
> Fetch the complete documentation index at: https://docs.skinloop.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify webhook signatures

> Verify the raw request before processing an event.

## Verify before parsing

Your public HTTPS endpoint receives the three headers below:

```http theme={null}
Skinloop-Event-Id: evt_01K5F8N7Y4A2BCDEFGHJKMNPQR
Skinloop-Timestamp: 1789733100
Skinloop-Signature: v1=<64 lowercase hexadecimal characters>
```

Use the endpoint signing secret to compute HMAC-SHA256 over:

```text theme={null}
<eventId>.<timestamp>.<exact UTF-8 request bytes>
```

`Skinloop-Timestamp` is the current Unix timestamp in seconds, encoded as a
decimal string. Compare the lowercase hexadecimal digest with
`Skinloop-Signature: v1=...` using a constant-time comparison:

```ts theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

function validSignature(rawBody: Buffer, eventId: string, timestamp: string,
  signature: string, secret: string) {
  const message = Buffer.concat([
    Buffer.from(eventId, "utf8"),
    Buffer.from(".", "utf8"),
    Buffer.from(timestamp, "utf8"),
    Buffer.from(".", "utf8"),
    rawBody
  ]);
  const expected = createHmac("sha256", secret).update(message).digest("hex");
  const received = signature.startsWith("v1=") ? signature.slice(3) : "";
  return received.length === expected.length &&
    timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}
```

Use the timestamp to enforce a freshness window configured by your receiver.
Reject malformed headers, stale timestamps, unknown secrets, and altered
bodies. Read the request body as raw bytes first; never parse and reserialize
it before verification. Store the event ID only after authentication. After
verification, require `Skinloop-Event-Id` to equal the body `id`. See the
[complete webhook requests](/webhooks/overview#completed-delivery) for all
headers and body fields.

## Complete Node and Express receiver

Install Express and your PostgreSQL client, then register this route **before**
any global `express.json()` middleware. The route uses `express.raw()` because
parsing and reserializing JSON changes the signed bytes.

```ts theme={null}
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
import { Pool, PoolClient } from "pg";
import { recordEventAndEnqueue } from "./orders.js";

const app = express();
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const MAX_CLOCK_SKEW_SECONDS = 5 * 60;
const MAX_BODY_BYTES = "256kb";

function requiredHeader(
  req: express.Request,
  name: "skinloop-event-id" | "skinloop-timestamp" | "skinloop-signature"
) {
  const value = req.headers[name];
  if (typeof value !== "string" || value.includes(",")) {
    throw new Error(`missing or repeated ${name}`);
  }
  return value;
}

function verifyTimestamp(timestamp: string) {
  if (!/^[0-9]{1,20}$/.test(timestamp)) throw new Error("invalid timestamp");
  const signedAt = Number(timestamp);
  const now = Math.floor(Date.now() / 1000);
  if (!Number.isSafeInteger(signedAt)) throw new Error("invalid timestamp");
  if (Math.abs(now - signedAt) > MAX_CLOCK_SKEW_SECONDS) {
    throw new Error("stale timestamp");
  }
}

function validSignature(
  rawBody: Buffer,
  eventId: string,
  timestamp: string,
  signature: string,
  secrets: string[]
) {
  const match = /^v1=([0-9a-f]{64})$/.exec(signature);
  if (!match) return false;
  const received = Buffer.from(match[1], "hex");
  const message = Buffer.concat([
    Buffer.from(`${eventId}.${timestamp}.`, "utf8"),
    rawBody
  ]);

  // Evaluate every configured secret rather than stopping after the first
  // match. This supports a short, controlled rotation overlap.
  let valid = false;
  for (const secret of secrets) {
    const expected = createHmac("sha256", secret).update(message).digest();
    valid = timingSafeEqual(received, expected) || valid;
  }
  return valid;
}

type SkinloopEvent = {
  version: "1";
  id: string;
  type: string;
  createdAt: string;
  data: {
    externalPaymentId: string;
    merchantOrderId?: string;
    game: "cs2" | "rust";
    currency: "USD";
    amount: string;
    status: string;
    reservationRequired: boolean;
    fulfillmentAllowed: boolean;
    holdUntil?: string;
  };
};

async function storeEvent(
  client: PoolClient,
  event: SkinloopEvent,
  bodyDigest: string
) {
  const inserted = await client.query(
    `INSERT INTO skinloop_webhook_events
       (event_id, event_type, created_at, body_sha256, payload)
     VALUES ($1, $2, $3, $4, $5::jsonb)
     ON CONFLICT (event_id) DO NOTHING
     RETURNING event_id`,
    [event.id, event.type, event.createdAt, bodyDigest, JSON.stringify(event)]
  );
  if (inserted.rowCount === 1) return "inserted";

  const existing = await client.query(
    `SELECT body_sha256
       FROM skinloop_webhook_events
      WHERE event_id = $1
      FOR UPDATE`,
    [event.id]
  );
  if (existing.rows[0]?.body_sha256 !== bodyDigest) {
    throw new Error("event identity conflict");
  }
  return "duplicate";
}

app.post(
  "/webhooks/skinloop",
  express.raw({ type: "application/json", limit: MAX_BODY_BYTES }),
  async (req, res) => {
    if (!Buffer.isBuffer(req.body)) {
      return res.status(400).json({ error: "raw_body_required" });
    }

    let eventId: string;
    let timestamp: string;
    let signature: string;
    try {
      eventId = requiredHeader(req, "skinloop-event-id");
      timestamp = requiredHeader(req, "skinloop-timestamp");
      signature = requiredHeader(req, "skinloop-signature");
      if (!/^evt_[A-Za-z0-9]+$/.test(eventId)) {
        throw new Error("invalid event id");
      }
      verifyTimestamp(timestamp);
    } catch {
      return res.status(400).json({ error: "invalid_webhook_headers" });
    }

    const secrets = [
      process.env.SKINLOOP_WEBHOOK_SECRET_CURRENT,
      process.env.SKINLOOP_WEBHOOK_SECRET_PREVIOUS
    ].filter((value): value is string => Boolean(value));
    if (
      secrets.length === 0 ||
      !validSignature(req.body, eventId, timestamp, signature, secrets)
    ) {
      return res.status(401).json({ error: "invalid_signature" });
    }

    let event: SkinloopEvent;
    try {
      event = JSON.parse(req.body.toString("utf8")) as SkinloopEvent;
      if (
        event === null ||
        event.version !== "1" ||
        event.id !== eventId ||
        typeof event.type !== "string" ||
        typeof event.createdAt !== "string" ||
        event.data === null ||
        typeof event.data !== "object" ||
        typeof event.data.status !== "string"
      ) {
        throw new Error("invalid event");
      }
    } catch {
      return res.status(400).json({ error: "invalid_event" });
    }

    const bodyDigest = createHash("sha256").update(req.body).digest("hex");
    let client: PoolClient | undefined;
    try {
      client = await db.connect();
      await client.query("BEGIN");
      const result = await storeEvent(client, event, bodyDigest);

      if (result === "inserted") {
        // Record state and enqueue the same unique fulfillment job used by
        // status polling. Do not call an external fulfillment service here.
        await recordEventAndEnqueue(client, event);
      }

      await client.query("COMMIT");
      return res.status(200).json({
        received: true,
        duplicate: result === "duplicate"
      });
    } catch (error) {
      await client?.query("ROLLBACK").catch(() => undefined);
      if (
        error instanceof Error &&
        error.message === "event identity conflict"
      ) {
        return res.status(409).json({ error: "event_identity_conflict" });
      }
      return res.status(500).json({ error: "temporary_processing_failure" });
    } finally {
      client?.release();
    }
  }
);

// Register normal JSON parsing only after the webhook route.
app.use(express.json());
```

Create the corresponding event table:

```sql theme={null}
CREATE TABLE skinloop_webhook_events (
  event_id TEXT PRIMARY KEY,
  event_type TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL,
  body_sha256 TEXT NOT NULL CHECK (body_sha256 ~ '^[0-9a-f]{64}$'),
  payload JSONB NOT NULL,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

`recordEventAndEnqueue` represents your local transactional status update and
durable fulfillment-job insert from [Safe fulfillment](/fulfillment-safety).
Implement it with your own order schema. It must use the supplied database
client so event storage and job creation commit together.

## Timestamp policy

The receiver must reject a timestamp more than **five minutes** in the past or
future relative to its own UTC clock. This limits replay exposure while
allowing ordinary network delay and clock skew.

* Synchronize production hosts with a reliable time service.
* Perform the timestamp check before HMAC work and JSON parsing.
* Do not use `createdAt` for signature freshness; use
  `Skinloop-Timestamp`.
* Do not treat the freshness check as deduplication. An attacker can replay a
  valid request within five minutes, so the event-ID unique constraint remains
  required.

## Endpoint secrets

Each webhook endpoint has its own signing secret beginning with `whsec_`.
Skinloop displays the full secret only when the endpoint is created or its
secret is rotated. Store it as a server-side secret:

```text theme={null}
SKINLOOP_WEBHOOK_SECRET_CURRENT=whsec_...
SKINLOOP_WEBHOOK_SECRET_PREVIOUS=whsec_...
```

Never put either value in browser code, source control, logs, support messages,
or the event database. A secret belongs only to the endpoint for which it was
issued; do not reuse it between staging and production.

## Rotate without dropping events

The direct **Rotate secret** action replaces an endpoint's secret immediately
and returns the new value once. Because the new value cannot be deployed before
it is created, use two endpoint records for a planned zero-downtime rotation:

1. Create a second webhook endpoint with the same HTTPS URL and event
   subscriptions. Securely capture its new secret.
2. Deploy the new secret as `CURRENT` and the old endpoint's secret as
   `PREVIOUS`.
3. Trigger a webhook test for the new endpoint and confirm it verifies.
4. Revoke the old endpoint in Skinloop.
5. Keep the old secret as `PREVIOUS` for one hour to cover requests already in
   flight.
6. Remove `PREVIOUS` and test the remaining endpoint again.

While both endpoints are active, the same event can be delivered through each
endpoint. The event-ID unique constraint makes that overlap safe.

For an emergency response to suspected exposure, use **Rotate secret**
immediately, update `CURRENT` as quickly as possible, and remove the compromised
secret rather than keeping an overlap. A short rejection window is safer than
accepting a known-compromised secret.

The receiver checks both configured secrets during planned overlap. Do not keep
old secrets indefinitely.

## Response contract

| Situation                                                    | Response                                         | Skinloop behavior                                          |
| ------------------------------------------------------------ | ------------------------------------------------ | ---------------------------------------------------------- |
| Authenticated event committed                                | `200` with `{"received":true,"duplicate":false}` | Delivery accepted                                          |
| Authenticated duplicate with matching bytes                  | `200` with `{"received":true,"duplicate":true}`  | Delivery accepted; no repeated state change or fulfillment |
| Missing, repeated, malformed, stale, or future-dated headers | `400`                                            | Delivery rejected                                          |
| Signature does not verify with current or previous secret    | `401`                                            | Delivery rejected                                          |
| Header event ID differs from body `id`, or body is malformed | `400`                                            | Delivery rejected                                          |
| Same authenticated event ID with different bytes             | `409`                                            | Conflict requires investigation                            |
| Temporary database or enqueue failure                        | `500`                                            | Skinloop can retry                                         |

Return 2xx only after durable storage and any required fulfillment job commit.
Do not return 2xx first and continue important work only in process memory.

**Next:** test retries and accepted duplicates in
[Webhook retries and deduplication](/webhooks/retries).
