> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.thepennyinc.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.thepennyinc.com/_mcp/server.

# Verify Webhook Signatures

Penny uses the same outbound signing scheme for asynchronous event deliveries and synchronous Live Decisioning requests. Configure a signing secret on each endpoint whose requests you want to verify. Signing is separate from the optional [bearer token](/platform/webhook-authorization).

## Configure signing

Webhook creation leaves signing unconfigured. After setup finishes, call `POST /notifications/webhooks/{notification_endpoint_id}/signing_secret` to create a secret (201). Creation fails with 422 if a secret already exists. Use `POST /notifications/webhooks/{notification_endpoint_id}/signing_secret/rotate` to replace an existing secret (200); rotation fails with 422 if no secret exists. When a `signing_secret` is present, each event or decision request carries two additional headers:

| Header                    | Value                                                                                                                          |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `Penny-Webhook-Timestamp` | The time Penny sent the request, as a Unix timestamp in seconds (a string such as `1756130591`)                                |
| `Penny-Webhook-Signature` | `sha256=<hex digest>` — an HMAC-SHA256 over the timestamp and the raw request body, keyed with the endpoint's `signing_secret` |

The signing secret itself is never sent in a request; only the timestamp and signature are. The signed payload is the timestamp, a literal period, and the raw request body, all as UTF-8 bytes exactly as received:

```text
signed_payload = <Penny-Webhook-Timestamp> + "." + <raw request body>
```

To verify a request:

1. Read the `Penny-Webhook-Timestamp` and `Penny-Webhook-Signature` headers.
2. Reject the request if the timestamp is more than a few minutes (5 is a reasonable window) away from your current time. Because the timestamp is part of the signed payload, an attacker can't change it without invalidating the signature — this is what stops a captured delivery from being replayed later.
3. Recompute HMAC-SHA256 over `signed_payload` using your `signing_secret`, hex-encode the digest, and prefix it with `sha256=`.
4. Compare the result to the `Penny-Webhook-Signature` header using a constant-time comparison. Only process the request if they match.

Always hash the raw body bytes as they arrived — parse the JSON only after the signature checks out. Re-serializing the parsed object can change whitespace or key order and produce a different digest.

**`Python`**

```python title="Python"
import hmac
import hashlib
import time

TOLERANCE_SECONDS = 300


def verify_penny_signature(headers: dict, raw_body: bytes, signing_secret: str) -> bool:
    timestamp = headers.get("Penny-Webhook-Timestamp")
    signature = headers.get("Penny-Webhook-Signature")
    if not timestamp or not signature:
        return False

    # Replay protection: reject deliveries outside the tolerance window.
    try:
        sent_at = int(timestamp)
    except ValueError:
        return False
    if abs(time.time() - sent_at) > TOLERANCE_SECONDS:
        return False

    signed_payload = timestamp.encode("utf-8") + b"." + raw_body
    digest = hmac.new(
        signing_secret.encode("utf-8"),
        signed_payload,
        hashlib.sha256,
    ).hexdigest()
    expected = f"sha256={digest}"

    return hmac.compare_digest(expected, signature)
```

**`Node.js / TypeScript`**

```typescript title="Node.js / TypeScript"
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

function verifyPennySignature(
  headers: Record<string, string | undefined>,
  rawBody: Buffer,
  signingSecret: string,
): boolean {
  // Most Node HTTP frameworks (Express, http.IncomingMessage, ...) lower-case
  // header names for you; adjust the keys here if yours doesn't.
  const timestamp = headers["penny-webhook-timestamp"];
  const signature = headers["penny-webhook-signature"];
  if (!timestamp || !signature) return false;

  // Replay protection: reject deliveries outside the tolerance window.
  const sentAt = Number(timestamp);
  if (!Number.isInteger(sentAt) || Math.abs(Date.now() / 1000 - sentAt) > TOLERANCE_SECONDS) {
    return false;
  }

  const signedPayload = Buffer.concat([
    Buffer.from(timestamp, "utf-8"),
    Buffer.from("."),
    rawBody,
  ]);
  const digest = createHmac("sha256", signingSecret)
    .update(signedPayload)
    .digest("hex");
  const expected = `sha256=${digest}`;

  // timingSafeEqual throws on a length mismatch rather than returning false,
  // so check lengths first — an attacker-controlled signature header could
  // otherwise throw on every legitimate rejection.
  const expectedBuffer = Buffer.from(expected, "utf-8");
  const signatureBuffer = Buffer.from(signature, "utf-8");
  if (expectedBuffer.length !== signatureBuffer.length) return false;

  return timingSafeEqual(expectedBuffer, signatureBuffer);
}
```

Signing-secret creation and rotation return only:

```json
{ "signing_secret": "penny_whsec_...", "algorithm": "HMAC-SHA256" }
```

Store this value securely when it is returned. Ordinary webhook responses expose `has_signing_secret` and never include the secret, even with `reveal_sensitive=true` or when retrieving history. Use the entire `penny_whsec_...` string as the HMAC key. Rotation takes effect immediately for new requests; there is no overlap period. Requests already in flight may still carry the previous signature. If you lose the response, rotate again to obtain a new secret.

The endpoint must already be provisioned before you create its signing secret. Rotate the secret if it is lost or compromised, then update your receiver to use the new value. If you operate multiple endpoints, store the appropriate secret for each endpoint; do not choose one based only on unverified body data.