> ## Documentation Index
> Fetch the complete documentation index at: https://dev.enterprise.moonpay.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> MoonPay Enterprise sends a signed webhook to your endpoint when a customer, transaction, autoramp, fiat address, or identification changes. The payload contains the event type and the associated data.

## Webhook Console

Webhooks are managed in the Partner Dashboard under **Developer > Webhooks**. The page has three tabs:

* **Webhook Logs**: every delivery attempt your endpoint answered, with the response code and how long your endpoint took
* **Manage Webhooks**: the endpoints you have registered, and the events each one receives
* **Integration Guide**: sample code for verifying signatures in your language

<Frame caption="Manage Webhooks: the endpoints registered on your account">
  <img src="https://mintcdn.com/moonpayiron/sUF2AMT2dLNIqek1/images/clips/register-webhook-1-endpoints/01-manage-webhooks.png?fit=max&auto=format&n=sUF2AMT2dLNIqek1&q=85&s=3e0e05b1ae5a0755a626b5b9ec4861d6" alt="Manage Webhooks tab listing two registered endpoints with status, topics, and a Ping button" width="1600" height="1000" data-path="images/clips/register-webhook-1-endpoints/01-manage-webhooks.png" />
</Frame>

### Fields

* **Label**: The name of your webhook. If a delivery to it failed, a warning icon appears next to the name. Hover over it for the failure reason
* **Status**: Whether the endpoint is enabled. A disabled endpoint stays registered and receives nothing
* **Topics**: The events this endpoint is subscribed to
* **Ping**: Sends a test delivery to the endpoint, so you can confirm your handler answers before real events arrive

### Delivery logs

The Webhook Logs tab lists every attempt your endpoint answered with an HTTP status, newest first. Timeouts and connection errors don't create a row. For those, check the warning icon next to the webhook's label. Open a row for the full request: the signed headers, the exact JSON body sent, the response code, and a **Retry delivery** button for the ones that failed.

<Frame caption="Webhook Logs: one row per delivery attempt">
  <img src="https://mintcdn.com/moonpayiron/sUF2AMT2dLNIqek1/images/clips/inspect-webhook-delivery/01-delivery-log.png?fit=max&auto=format&n=sUF2AMT2dLNIqek1&q=85&s=502cfc663d39e8311b41200fdbf832a2" alt="Webhook Logs tab listing delivery attempts with status, response code, event kind, and duration" width="1600" height="1000" data-path="images/clips/inspect-webhook-delivery/01-delivery-log.png" />
</Frame>

## Register Webhook

Click **Register Webhook** on the Webhooks page.

<Frame caption="Create webhook">
  <img src="https://mintcdn.com/moonpayiron/sUF2AMT2dLNIqek1/images/clips/register-webhook-2-create-form/01-create-webhook-form.png?fit=max&auto=format&n=sUF2AMT2dLNIqek1&q=85&s=7219cd34800e249fb3a0d2a487aa74b1" alt="Create webhook form with Label, Endpoint URL, and the selectable event list" width="1600" height="1000" data-path="images/clips/register-webhook-2-create-form/01-create-webhook-form.png" />
</Frame>

Fill in three things:

* **Label**: Shown in the webhook list and in the delivery logs
* **Endpoint URL**: A public `https://` URL that returns a 2xx within 5 seconds
* **Events**: The event kinds delivered to this endpoint. `ping` is always on, so a test delivery works on every endpoint

Once you create the webhook, the next screen shows the `webhook secret`. This is needed to validate the webhook signature.

<Warning>
  The secret is shown once, at creation. Copy it before leaving the screen. If you lose it, register a new webhook.
</Warning>

## Implement Webhooks

The Partner Area webhook console provides sample code for webhook signature validation. However, additional information can be found below.

Overview

* Webhook ID: newEvent
* Method: POST
* Content Type: application/json
* Authentication: HMAC-SHA256 signature (via headers)
* Trigger: Emitted when a relevant customer or platform event occurs

### Receiving Webhook Requests

MoonPay Enterprise will send webhook notifications to the endpoint URL you’ve registered during integration setup. Each request includes headers for verification, and a JSON body describing the event.

#### HTTP Headers

MoonPay Enterprise uses the [Standard Webhooks](https://www.standardwebhooks.com/) header names, but not its signature scheme. Every delivery includes these headers:

| Header | Description |
| - | - |
| `content-type` | Always `application/json` |
| `user-agent` | `MoonPay-Enterprise-Webhooks/1.0`. If you allowlist by user agent, match the `MoonPay-Enterprise-Webhooks/` prefix |
| `webhook-id` | Unique UUID of the event. It stays the same across all retries of the event, including a retry you trigger from the Partner Area, so use it to deduplicate |
| `webhook-timestamp` | Unix timestamp in seconds of when the webhook was sent |
| `webhook-signature` | Signature used to verify request authenticity. Format: `v1=<hex-encoded HMAC-SHA256>` |

### Signature Verification

Verify the signature with your webhook secret:

<Steps>
  <Step>
    Extract the `webhook-timestamp` and `webhook-signature` from the headers.
  </Step>

  <Step>
    Remove the `v1=` prefix from the signature.
  </Step>

  <Step>
    Concatenate `webhook-timestamp` + `raw_body` (no whitespace or formatting).
  </Step>

  <Step>
    Compute the HMAC-SHA256 digest using your webhook secret key.
  </Step>

  <Step>
    Reject the request if `webhook-timestamp` falls outside your tolerance window. The examples below use 5 minutes.
  </Step>

  <Step>
    Use constant-time comparison to check if the computed digest matches the signature.
  </Step>
</Steps>

<Warning>
  Always verify the signature using constant-time comparison to prevent timing attacks.
</Warning>

<Warning>
  The official Standard Webhooks libraries reject these signatures. MoonPay Enterprise signs `webhook-timestamp` + raw body with no `webhook-id` and no `.` separators, uses the full secret string (including the `whsec_` prefix) as the HMAC key without base64-decoding it, and sends a hex digest as `v1=<hex>`. Follow the steps above instead.
</Warning>

### Webhook Payload

Webhook requests include a top-level `WebhookContainer` object. The message field varies depending on the event type.

#### Example Payloads

Below is one example payload per `type` the webhook can deliver. Each block is a complete `WebhookContainer`, the exact shape MoonPay Enterprise POSTs to your endpoint.

`transaction`: a new transaction happened for this customer. See [Autoramp](/autoramp).

```json theme={null}
{
  "type": "transaction",
  "timestamp": "2025-06-02T14:59:26.769468+00:00",
  "data": {
    "customer_id": "2ff3e394-978b-4489-8795-0a4e769a04c6",
    "message": {
      "Event": {
        "id": "7d834f68-cea8-496a-8eae-bb0772365028",
        "kind": "Transaction"
      }
    }
  }
}
```

`new_autoramp`: a new autoramp was created by or for this customer. See [Autoramp](/autoramp).

```json theme={null}
{
  "type": "new_autoramp",
  "timestamp": "2025-06-02T14:59:26.769588+00:00",
  "data": {
    "customer_id": "4dcfe5af-3947-4928-b17f-a8fb13a68758",
    "message": {
      "Event": {
        "id": "87f1a893-3691-494d-b815-215f5486a5b4",
        "kind": "NewAutoramp"
      }
    }
  }
}
```

`new_bank_account`: a new bank account was registered by or for this customer. See [Fiat addresses](/fiat-addresses).

```json theme={null}
{
  "type": "new_bank_account",
  "timestamp": "2025-06-02T14:59:26.769593+00:00",
  "data": {
    "customer_id": "0e98736e-0be6-4f76-9451-ec2fab006dd4",
    "message": {
      "Event": {
        "id": "1cd9daaa-1ad1-48b8-a418-a3964dee1028",
        "kind": "NewBankAccount"
      }
    }
  }
}
```

`deposit_address_created`: the autoramp with `id` received a deposit address. See [Autoramp](/autoramp).

```json theme={null}
{
  "type": "deposit_address_created",
  "timestamp": "2025-06-02T14:59:26.769598+00:00",
  "data": {
    "customer_id": "45cd0169-7978-49b0-99ed-cb855acdba79",
    "message": {
      "Event": {
        "id": "c8422122-5221-478a-95f4-953157277753",
        "kind": "DepositAddressCreated"
      }
    }
  }
}
```

`customer_created`: a new customer has been created. See [Onboarding](/onboarding).

```json theme={null}
{
  "type": "customer_created",
  "timestamp": "2025-06-02T14:59:26.769647+00:00",
  "data": {
    "customer_id": "af0315f0-a683-4330-a7b9-ca0c6a19ce87",
    "message": {
      "Event": {
        "id": "6be8dd21-52a5-4616-b57d-54c4ec8acbf3",
        "kind": "CustomerCreated"
      }
    }
  }
}
```

`transaction_status`: the status of a transaction changed. See [Transaction status](/transaction-status).

```json theme={null}
{
  "type": "transaction_status",
  "timestamp": "2025-06-02T14:59:26.769658+00:00",
  "data": {
    "customer_id": "ebcea2b8-7caf-4ecf-ac59-95c5a6d7fefa",
    "message": {
      "TransactionStatus": {
        "id": "e4f31eb4-da3a-4776-b70e-856a88492a17",
        "status": "Pending",
        "transaction_status": "ConversionInProgress",
        "transaction_hash": "0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060"
      }
    }
  }
}
```

`transaction_refunded`: a refund was recorded for a transaction. See [Transaction status](/transaction-status).

```json theme={null}
{
  "type": "transaction_refunded",
  "timestamp": "2025-06-02T14:59:26.769659+00:00",
  "data": {
    "customer_id": "ebcea2b8-7caf-4ecf-ac59-95c5a6d7fefa",
    "message": {
      "TransactionRefunded": {
        "refund_id": "1b3f5a9e-7c2d-4f6a-9e8b-2a4c6d8f0e1b",
        "transaction_id": "e4f31eb4-da3a-4776-b70e-856a88492a17"
      }
    }
  }
}
```

`register_fiat_address_status`: the status of a fiat address changed. See [Fiat addresses](/fiat-addresses). This fires for every fiat address registration, including accounts with no external provider step that go straight to `Registered`.

```json theme={null}
{
  "type": "register_fiat_address_status",
  "timestamp": "2025-06-02T14:59:26.769663+00:00",
  "data": {
    "customer_id": "0ae92a7d-82c5-4e98-ad6e-3dbb6e573b2c",
    "message": {
      "RegisterFiatAddressStatus": {
        "id": "bf1b777d-2f46-4f7c-a5be-b6f825cfcd7b",
        "status": "AuthorizationRequired"
      }
    }
  }
}
```

`customer_status`: the status of a customer has changed. See [Onboarding](/onboarding).

```json theme={null}
{
  "type": "customer_status",
  "timestamp": "2025-06-02T14:59:26.769750+00:00",
  "data": {
    "customer_id": "b714a479-0cf8-4390-9f24-d32df02dff36",
    "message": {
      "CustomerStatus": {
        "id": "eb1bbc6a-1256-4653-badf-7d0d7e96deba",
        "status": "Active"
      }
    }
  }
}
```

`customer_fiat_abilities`: the customer's fiat deposit or payout rails changed. See [Onboarding](/onboarding#ability-status). Subscribe to this instead of polling `GET /api/customers/{id}/abilities`. MoonPay Enterprise recomputes abilities and fires this event when an identification is approved, a signing is recorded, a compliance review changes the customer, or a banking provider finishes registering a rail.

```json expandable theme={null}
{
  "type": "customer_fiat_abilities",
  "timestamp": "2025-06-02T14:59:26.769755+00:00",
  "data": {
    "customer_id": "b714a479-0cf8-4390-9f24-d32df02dff36",
    "message": {
      "CustomerFiatAbilities": {
        "id": "b714a479-0cf8-4390-9f24-d32df02dff36",
        "fiat_deposit": {
          "usd": {
            "ach": "Active",
            "ach_thirdparty": "Active",
            "wire": "Active",
            "wire_thirdparty": "Active",
            "rtp": "Unavailable",
            "rtp_thirdparty": "Unavailable",
            "fednow": "Unavailable",
            "fednow_thirdparty": "Unavailable",
            "swift": "Active",
            "swift_thirdparty": "Unavailable"
          },
          "eur": {
            "sepa": "Active",
            "sepa_thirdparty": "Active",
            "swift": "Unavailable",
            "swift_thirdparty": "Unavailable"
          },
          "gbp": {
            "fps": "Active",
            "fps_thirdparty": "Active",
            "chaps": "Active",
            "chaps_thirdparty": "Active",
            "swift": "Unavailable",
            "swift_thirdparty": "Unavailable"
          },
          "brl": {
            "pix": "Pending",
            "pix_thirdparty": "Unavailable"
          }
        },
        "fiat_payout": {
          "usd": {
            "ach": "Active",
            "ach_thirdparty": "Active",
            "wire": "Active",
            "wire_thirdparty": "Active",
            "rtp": "Unavailable",
            "rtp_thirdparty": "Unavailable",
            "fednow": "Unavailable",
            "fednow_thirdparty": "Unavailable",
            "swift": "Active",
            "swift_thirdparty": "Unavailable"
          },
          "eur": {
            "sepa": "Active",
            "sepa_thirdparty": "Active",
            "swift": "Unavailable",
            "swift_thirdparty": "Unavailable"
          },
          "gbp": {
            "fps": "Active",
            "fps_thirdparty": "Active",
            "chaps": "Active",
            "chaps_thirdparty": "Active",
            "swift": "Unavailable",
            "swift_thirdparty": "Unavailable"
          },
          "brl": {
            "pix": "Unavailable",
            "pix_thirdparty": "Unavailable"
          }
        }
      }
    }
  }
}
```

<Note>
  `id` is the customer, the same UUID as `data.customer_id`.

  Each delivery carries the customer's full fiat ability snapshot, not a diff, and MoonPay Enterprise does not compare it against what it sent last time. The same snapshot can arrive twice, so diff the payload against your stored copy and act on the rails that actually moved.

  The snapshot covers `fiat_deposit` and `fiat_payout` only. It does not include the `currencies` array that `GET /api/customers/{id}/abilities` returns, so call the endpoint when you need per-currency flow availability (mint, redeem, onramp, offramp, swap).
</Note>

`register_autoramp_status`: the status of an autoramp has changed. See [Autoramp status](/autoramp-status).

```json theme={null}
{
  "type": "register_autoramp_status",
  "timestamp": "2025-06-02T14:59:26.769759+00:00",
  "data": {
    "customer_id": "357c34d3-7abe-4334-b8b5-6f8e84ba756e",
    "message": {
      "RegisterAutorampStatus": {
        "id": "bc739df1-2816-4034-8b36-b99510370f18",
        "status": "Created"
      }
    }
  }
}
```

`identification_status`: the status of an identification has changed. See [KYC](/kyc).

```json theme={null}
{
  "type": "identification_status",
  "timestamp": "2025-06-02T14:59:26.769768+00:00",
  "data": {
    "customer_id": "8a7c34d3-7abe-4334-b8b5-6f8e84ba756f",
    "message": {
      "IdentificationStatus": {
        "id": "cd849ef2-3927-5145-c947-c110481f29ca",
        "status": "Approved"
      }
    }
  }
}
```

`ping`: a ping event, sent during webhook setup or from the **Ping** button in the Webhook Console.

```json theme={null}
{
  "type": "ping",
  "timestamp": "2025-06-02T14:59:26.769763+00:00",
  "data": {
    "customer_id": "59f42528-3c5a-4448-b2df-65369adcee42",
    "message": {
      "Ping": {
        "id": "1d9c36fb-83bf-49cc-9b1f-aff970b6ce96"
      }
    }
  }
}
```

### Payload Schema

**WebhookContainer** (object)

| Field | Type | Required | Description |
| - | - | - | - |
| type | string | Yes | Event type in `snake_case`, for example `transaction_status`. |
| timestamp | string | Yes | ISO 8601 timestamp of when the event was triggered. |
| data | object | Yes | Event-specific data payload. |

**WebhookNotification** (inside data)

| Field | Type | Required | Description |
| - | - | - | - |
| customer\_id | string | Yes | UUID of the affected customer. |
| message | object | Yes | The core event message. Varies by event type. |

<Info>
  The delivered `type` is `snake_case` (`transaction_status`). The topics you subscribe a webhook to are PascalCase (`TransactionStatus`). Both name the same events, so map between the two casings when you route deliveries.
</Info>

### Supported Event Types

Each message object follows a typed schema depending on the type of event.

1. **WebhookEventMessage**

General-purpose events (e.g., onboarding, registration).

```json theme={null}
{  
  "id": "uuid",  
  "kind": "Transaction" | "NewBankAccount" | "NewAutoramp" | "DepositAddressCreated" | "CustomerCreated"  
}
```

2. **WebhookFiatAddressStatusMessage**

Status of a fiat vIBAN account.

```json theme={null}
{  
  "id": "uuid",  
  "status": "AuthorizationRequired" | "AuthorizationFailed" | "RegistrationPending" | "RegistrationFailed" | "Registered"  
}
```

3. **WebhookAutorampStatusMessage**

Status changes in an Autoramp.

```json theme={null}
{
  "id": "uuid",
  "status": "Created" | "EditPending" | "Authorized" | "DepositAccountAdded" | "Approved" | "Rejected" | "Cancelled"
}
```

4. **WebhookTransactionStatusMessage**

Real-time updates on transactions.

```json theme={null}
{  
  "id": "uuid",  
  "status": "Pending" | "PayoutPending" | "Payout" | "PayoutCompleted" | "Completed" | "Failed" | "InAmlReview" | "AmlRejected" | "AmountRejected" | "FraudRejected",
  "transaction_status": "FundsReviewInProgress" | "ConversionInProgress" | "PayoutInProgress" | "Completed" | "Failed" | "RejectedAml" | "RejectedFraud" | "RejectedMinAmount",
  "transaction_hash": "string (optional)"
}
```

<Note>
  The `status` field is **deprecated**. Use `transaction_status` instead for the current transaction state.

  The `transaction_hash` field is only present when the payout destination is a blockchain address (crypto payouts).
</Note>

5. **WebhookCustomerStatusMessage**

Customer onboarding and compliance status.

```json theme={null}
{  
  "id": "uuid",  
  "status": "UserRequired" | "SigningsRequired" | "IdentificationRequired" | "Active" | "Suspended" | "Archived"  
}
```

6. **WebhookCustomerFiatAbilitiesMessage**

The customer's current fiat deposit and payout rails.

```json theme={null}
{
  "id": "uuid",
  "fiat_deposit": { "usd": { ... }, "eur": { ... }, "gbp": { ... }, "brl": { ... } },
  "fiat_payout": { "usd": { ... }, "eur": { ... }, "gbp": { ... }, "brl": { ... } }
}
```

Every currency key is present. Each rail leaf is one of `Active`, `Pending`, `Unavailable`, `Blocked`, `Maintenance`. See [Ability Status](/onboarding#ability-status) for the rails each currency carries and what each status means.

7. **WebhookPingMessage**

Sent during webhook setup or testing.

```json theme={null}
{  
  "id": "uuid"  
}
```

8. **WebhookIdentificationStatusMessage**

Identification status during KYC/KYB process.

```json theme={null}
{  
  "id": "uuid",  
  "status": "Pending" | "Processed" | "PendingReview" | "Approved" | "Declined" | "Expired" | "Archived"  
}
```

`Pending` → Customer has not started the process, or a business submission is waiting on missing items (resume `url` on the identification)

`Processed` → Customer has completed the input process

`PendingReview` → Identification is ready for review by Compliance Team

`Approved` → Identification has been approved by Compliance Team

`Declined` → Identification has been declined by Compliance Team

`Expired` → The identification process was not completed and has been expired

`Archived` → The identification is no longer active and has been archived

9. **WebhookTransactionRefundedMessage**

A refund recorded for a transaction.

```json theme={null}
{
  "refund_id": "uuid",
  "transaction_id": "uuid"
}
```

### Response

Your webhook endpoint must return a 2xx status within 5 seconds, for example:

`HTTP/1.1 200 OK`

Any 2xx acknowledges receipt. Any other status code, a timeout, or a connection error triggers a retry.

<Note>
  Log every `webhook-id` and response status for audit and troubleshooting.
</Note>

## Error Handling & Retries

* Failed deliveries (non-2xx status, timeout, or connection error) are retried with exponential backoff. The first retry comes after about 5 seconds, the wait doubles each time, and it caps at 20 minutes between attempts.
* After 20 failed retries, MoonPay Enterprise drops the event and does not send it again automatically.
* Delivery is at-least-once, so the same event can arrive more than once. Every retry carries the same `webhook-id`; deduplicate on it.
* You can see failing webhooks in the Partner Area

## Sample Signature

The `Raw payload` below is the exact byte string MoonPay Enterprise signs and POSTs: the full `WebhookContainer`, not just the inner notification. HMAC the `timestamp` + this raw body to reproduce the signature.

```bash theme={null}
Secret: whsec_1s/keE/2+3eQUBc+7kedMAFRoM0twsrBYPpGWbt2/csF6pbMws9RMDRU1wtRas0PwDYgDd3t7mamKhO4LBjBiQ
Raw payload: {"type":"ping","timestamp":"2025-05-21T13:49:31.000000+00:00","data":{"customer_id":"3f9830ca-a98e-4020-a25b-80f21da86c97","message":{"Ping":{"id":"0196f318-b593-7803-a8f1-047d53179e06"}}}}
signatureHeader: v1=2cf8c0fc4f7485802689868f4f060ecc942d7a636ed2f14847b8bc4251f81a42
timestamp: 1747835371
deliveryId: f22ba628-4ab6-4a01-8d08-ff5de0ca2334
```

## Example Code

<CodeGroup>
  ```php PHP expandable theme={null}
  <?php

  define('WEHO_SECRET', 'whsec_fmKXLgE2fTRYnCCfkRppSGL0JXZPUeqvbdApGrvCQpNd8plfTCMUvgTS8q+/387W3+XphAL8FT44fRIeAmvaTw');
  define('TIMESTAMP_TOLERANCE', 5 * 60); // 5 minutes

  // Helper: Read raw body and headers
  $rawBody = file_get_contents("php://input");
  $headers = getallheaders();

  $signatureHeader = $headers["webhook-signature"] ?? '';
  $timestamp = $headers["webhook-timestamp"] ?? '';
  $deliveryId = $headers["webhook-id"] ?? '';

  error_log("signatureHeader: $signatureHeader");
  error_log("timestamp: $timestamp");
  error_log("deliveryId: $deliveryId");

  if (!$signatureHeader || !$timestamp || !$deliveryId) {
      http_response_code(400);
      echo "Missing one of required headers: webhook-id, webhook-timestamp, webhook-signature";
      exit;
  }

  if (!str_starts_with($signatureHeader, "v1=")) {
      http_response_code(400);
      echo "Invalid signature format";
      exit;
  }

  $receivedSig = substr($signatureHeader, 3);
  $ts = intval($timestamp);
  if (!$ts) {
      http_response_code(400);
      echo "Invalid webhook-timestamp";
      exit;
  }

  $now = time();
  if (abs($now - $ts) > TIMESTAMP_TOLERANCE) {
      http_response_code(400);
      echo "Webhook timestamp outside of tolerance window";
      exit;
  }

  $signedPayload = $timestamp . $rawBody;
  $computedHmac = hash_hmac('sha256', $signedPayload, WEHO_SECRET);

  if (!hash_equals($computedHmac, $receivedSig)) {
      http_response_code(401);
      echo "Invalid signature";
      exit;
  }

  // Log and respond
  $payload = json_decode($rawBody, true);
  error_log("Payload: " . print_r($payload, true));
  error_log("✔️ Received valid webhook $deliveryId");

  header("Content-Type: application/json");
  echo json_encode(["status" => "ok"]);
  ```

  ```javascript Javascript expandable theme={null}
  const express = require("express");
  const crypto = require("crypto");

  const app = express();
  app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf } }));

  const WEHO_SECRET = "whsec_XI+NCZLEYIveyE9JlGzrNz0uin0MW0hU9Ldb9kn+GPGuHEs1huWsov67uqmtUz9fRrgpLLeQwE2+rwv8dF/DJw";
  const TIMESTAMP_TOLERANCE = 5 * 60;

  app.post("/webhook", (req, res) => {
    console.log("Raw payload:", req.rawBody.toString());
    const signatureHeader = req.headers["webhook-signature"];
    const timestamp = req.headers["webhook-timestamp"];
    const deliveryId = req.headers["webhook-id"];

    console.log("signatureHeader:", signatureHeader);
    console.log("timestamp:", timestamp);
    console.log("deliveryId:", deliveryId);

    if (!signatureHeader || !timestamp || !deliveryId) {
      return res.status(400).send("Missing one of required headers: webhook-id, webhook-timestamp, webhook-signature");
    }

    if (!signatureHeader.startsWith("v1=")) {
      return res.status(400).send("Invalid signature format");
    }
    const receivedSig = signatureHeader.split("=", 2)[1];

    const ts = parseInt(timestamp, 10);
    if (isNaN(ts)) {
      return res.status(400).send("Invalid webhook-timestamp");
    }

    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - ts) > TIMESTAMP_TOLERANCE) {
      return res.status(400).send("Webhook timestamp outside of tolerance window");
    }

    const signedPayload = Buffer.concat([
      Buffer.from(timestamp, "utf8"),
      req.rawBody
    ]);

    const computedHmac = crypto
      .createHmac("sha256", WEHO_SECRET)
      .update(signedPayload)
      .digest("hex");

    // timingSafeEqual throws on unequal lengths, so check length first
    const isValid = computedHmac.length === receivedSig.length && crypto.timingSafeEqual(
      Buffer.from(computedHmac),
      Buffer.from(receivedSig)
    );

    if (!isValid) {
      return res.status(401).send("Invalid signature");
    }

    console.log("Payload:", req.body);
    console.info("✔️ Received valid webhook ", deliveryId, req.body);

    res.json({ status: "ok" });
  });

  app.listen(4422, "127.0.0.1", () => {
    console.log("Webhook server running on http://127.0.0.1:4422");
  });
  ```

  ```ruby Ruby expandable theme={null}
  require 'webrick'
  require 'openssl'
  require 'json'
  require 'time'
  require 'rack'

  WEHO_SECRET = 'whsec_zQtx6pI1m/i6j4Hi2A8ukDlaJUNYtG1Jqm+OH9FLvn8Juf/RzD3oABtBfJrGw4c17J4PXjri4GF2h1NZBc78aA'
  TIMESTAMP_TOLERANCE = 5 * 60 # 5 minutes

  server = WEBrick::HTTPServer.new(Port: 4424)

  server.mount_proc '/webhook' do |req, res|
    raw_body = req.body
    headers = req.header.transform_keys(&:downcase)

    signature_header = headers["webhook-signature"]&.first || ""
    timestamp = headers["webhook-timestamp"]&.first || ""
    delivery_id = headers["webhook-id"]&.first || ""

    puts "signatureHeader: #{signature_header}"
    puts "timestamp: #{timestamp}"
    puts "deliveryId: #{delivery_id}"

    if signature_header.empty? || timestamp.empty? || delivery_id.empty?
      res.status = 400
      res.body = "Missing one of required headers: webhook-id, webhook-timestamp, webhook-signature"
      next
    end

    unless signature_header.start_with?("v1=")
      res.status = 400
      res.body = "Invalid signature format"
      next
    end

    received_sig = signature_header[3..]
    ts = timestamp.to_i

    if ts == 0
      res.status = 400
      res.body = "Invalid webhook-timestamp"
      next
    end

    now = Time.now.to_i
    if (now - ts).abs > TIMESTAMP_TOLERANCE
      res.status = 400
      res.body = "Webhook timestamp outside of tolerance window"
      next
    end

    signed_payload = timestamp + raw_body
    computed_hmac = OpenSSL::HMAC.hexdigest('sha256', WEHO_SECRET, signed_payload)

    # For security reasons, use secure compare
    unless Rack::Utils.secure_compare(computed_hmac, received_sig)
      res.status = 401
      res.body = "Invalid signature"
      next
    end

    payload = JSON.parse(raw_body) rescue {}
    puts "Payload: #{payload}"
    puts "✔️ Received valid webhook #{delivery_id}"

    res['Content-Type'] = 'application/json'
    res.body = { status: 'ok' }.to_json
  end

  trap("INT") { server.shutdown }
  server.start
  ```

  ```python Python expandable theme={null}
  #!/usr/bin/env python3
  import os
  import hmac
  import hashlib
  import time
  from flask import Flask, request, abort, jsonify

  app = Flask(__name__)

  # The webhook secret
  WEHO_SECRET = "whsec_TJWDeK/U/JIDtD8Z9VJ00zsqjE7S/fwV47BTg4/hqO+2yi0PcHjaurd0C7S6kbsGwxeo/fX1+E0hjN+QkIHmNg"

  TIMESTAMP_TOLERANCE = 5 * 60

  @app.route("/webhook", methods=["POST"])
  def receive_webhook():
      # 1. Extract headers
      signature_header = request.headers.get("webhook-signature", "")
      print(signature_header)
      timestamp = request.headers.get("webhook-timestamp", "")
      print(timestamp)
      delivery_id = request.headers.get("webhook-id", "")
      print(delivery_id)

      if not signature_header or not timestamp or not delivery_id:
          abort(400, "Missing one of required headers: webhook-id, webhook-timestamp, webhook-signature")

      # 2. Verify signature format
      if not signature_header.startswith("v1="):
          abort(400, "Invalid signature format")
      received_sig = signature_header.split("=", 1)[1]

      # 3. Prevent replay by ensuring timestamp is recent
      try:
          ts = int(timestamp)
      except ValueError:
          abort(400, "Invalid webhook-timestamp")
      now = int(time.time())
      if abs(now - ts) > TIMESTAMP_TOLERANCE:
          abort(400, "Webhook timestamp outside of tolerance window")

      # 4. Compute our own HMAC-SHA256 over timestamp + raw body
      payload = request.get_data()  # raw bytes
      signed_payload = timestamp.encode("utf-8") + payload
      print(signed_payload)
      computed_hmac = hmac.new(
          key=WEHO_SECRET.encode("utf-8"),
          msg=signed_payload,
          digestmod=hashlib.sha256
      ).hexdigest()

      # 5. Constant-time compare
      if not hmac.compare_digest(computed_hmac, received_sig):
          abort(401, "Invalid signature")

      # 6. At this point the payload is verified
      data = request.get_json(silent=True) or {}
      print(data)
      app.logger.info(f"✔️ Received valid webhook {delivery_id}: %s", data)

      return jsonify({"status": "ok"}), 200

  if __name__ == "__main__":
      # Listen on localhost only (127.0.0.1) at port 4422
      app.run(host="127.0.0.1", port=4422)
  ```
</CodeGroup>
