# Handle Webhook

### Requirements

* An HTTPS endpoint that accepts `POST`
* PHP, Python, NodeJS, Java...

***

## Secure & Handle Webhook

When your server receives a webhook, you should **verify the signature** before processing it.\
This ensures the request really comes from the platform and not from someone else.

***

### Example payload (short)

Here is an example of the data you will receive, it contains all the necessary data related to the payment: user identifiers, basket contents, price, payment ID...

```json
{
  "mode": "live",
  "event": "payment.success",
  "created_at": "2025-09-04T13:45:44-04:00",
  "request_id": "51b97ba5891ec220e8b64385a00c3826",
  "webhook_id": "458",
  "store_id": "7541",
  "data": {
    "id": 71134,
    "transaction_id": "68B9D0471D02A",
    "gateway": "paypal",
    "amount": { "total_paid": 12, "currency": "EUR" },
    "user": { "email": "user@example.com", "discord_id": "123", "username": "Player" },
    "basket": [{ "id": 183, "name": "VIP Rank", "price": 10, "quantity": 1 }],
    "actions": { "...": "..." }
  }
}
```

***

### Create an endpoint

Create a file in your chosen language and start a server that listens for **POST requests** using the following examples:

{% tabs %}
{% tab title="PHP" %}
{% code title="endpoint.php" %}

```php
<?php
// Secret key (Tip4serv Dashboard -> Webhook -> Custom Webhook)
$webhookSecretBase64 = "y2x3...==";

// 1. Read the raw request body (JSON string)
$requestBody = file_get_contents('php://input');

// 2. Read headers sent by the platform
$timestampHeader = $_SERVER['HTTP_X_PAY_TIMESTAMP'] ?? '';
$signatureHeader = $_SERVER['HTTP_X_PAY_SIGNATURE'] ?? '';

// 3. Reject if the webhook is too old (max 5 minutes)
if (abs(time() - intval($timestampHeader)) > 300) {
  http_response_code(401);
  exit("invalid timestamp");
}

// 4. Recalculate the expected signature
$baseString = $timestampHeader . "." . $requestBody;
$secretBinary = base64_decode($webhookSecretBase64);
$expectedSignature = hash_hmac('sha256', $baseString, $secretBinary);

// 5. Compare signatures
if (!hash_equals($expectedSignature, $signatureHeader)) {
  http_response_code(401);
  exit("invalid signature");
}

// 6. If we’re here, the webhook is authentic
$event = json_decode($requestBody, true);

// Example: handle payment update
if ($event['event'] === 'payment.success') {
  $paymentId = $event['data']['id'];
  $discord_id = $event['data']['user']['discord_id'];
  // TODO: update your database here
}

http_response_code(200);
```

{% endcode %}

### How it works

* **Read the body** → the raw JSON payload sent by the platform.
* **Get headers** → `X-Pay-Timestamp` and `X-Pay-Signature` (provided in every request).
* **Check freshness** → reject if older than 5 minutes.
* **Recalculate signature** → `HMAC-SHA256(timestamp.body, secret)`.
* **Compare signatures** → use `hash_equals` to prevent timing attacks.
* **Process event** → decode JSON and handle it (e.g., update DB).
  {% endtab %}

{% tab title="Node.js + Express" %}
{% code title="endpoint.js" %}

```javascript
import express from "express";
import crypto from "crypto";

const app = express();
const PORT = 3000;

// Secret key (Tip4serv Dashboard -> Webhook -> Custom Webhook)
const WEBHOOK_SECRET = "y2x3...==";

// Middleware to capture raw body (important for signature verification)
app.use(express.raw({ type: "application/json" }));

app.post("/webhook", (req, res) => {
  const body = req.body.toString("utf8");
  const timestamp = req.header("X-Pay-Timestamp") || "";
  const signature = req.header("X-Pay-Signature") || "";

  // 1) Check freshness (max 5 minutes)
  if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
    return res.status(401).send("Invalid timestamp");
  }

  // 2) Recalculate expected signature
  const baseString = `${timestamp}.${body}`;
  const secret = Buffer.from(WEBHOOK_SECRET, "base64");
  const expected = crypto
    .createHmac("sha256", secret)
    .update(baseString)
    .digest("hex");

  // 3) Verify signature
  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
    return res.status(401).send("Invalid signature");
  }

  // 4) Parse JSON
  const event = JSON.parse(body);

  // Example: handle payment status update
  if (event.event === "payment.success") {
    const paymentId = event.data.id;
    const discord_id = event.data.user.discord_id;
    // TODO: update your database here
  }

  res.status(200).send("ok");
});

app.listen(PORT, () => {
  console.log(`Webhook server running on http://localhost:${PORT}`);
});
```

{% endcode %}

### How it works

* **Raw body** is required — don’t use `express.json()` directly, otherwise the signature check will fail.
* **Check timestamp** (`X-Pay-Timestamp`) — reject if older than 5 minutes.
* **Recalculate signature**: `HMAC-SHA256(timestamp.body, secret)` with the base64-decoded secret.
* **Compare with `X-Pay-Signature`** using `crypto.timingSafeEqual` for safety.
* **Process the event** (decode JSON and apply business logic).
  {% endtab %}

{% tab title="Python + Flask" %}
{% code title="endpoint.py" %}

```python
from flask import Flask, request, abort
import time, hmac, hashlib, base64, json

app = Flask(__name__)

# Secret key (Tip4serv Dashboard -> Webhook -> Custom Webhook)
WEBHOOK_SECRET_BASE64 = "y2x3...=="

@app.route("/webhook", methods=["POST"])
def webhook():
    # 1) Read the raw request body (JSON string)
    body = request.data.decode("utf-8")

    # 2) Read headers sent by the platform
    timestamp = request.headers.get("X-Pay-Timestamp", "")
    signature = request.headers.get("X-Pay-Signature", "")

    # 3) Reject if the webhook is too old (max 5 minutes)
    if abs(time.time() - int(timestamp)) > 300:
        abort(401, "invalid timestamp")

    # 4) Recalculate the expected signature
    base_string = f"{timestamp}.{body}"
    secret_binary = base64.b64decode(WEBHOOK_SECRET_BASE64)
    expected_signature = hmac.new(secret_binary, base_string.encode(), hashlib.sha256).hexdigest()

    # 5) Compare signatures
    if not hmac.compare_digest(expected_signature, signature):
        abort(401, "invalid signature")

    # 6) If we’re here, the webhook is authentic
    event = json.loads(body)

    # Example: handle payment update
    if event.get("event") == "payment.success":
        payment_id = event["data"]["id"]
        discord_id = event["data"]["user"]["discord_id"]
        # TODO: update your database here

    return "ok", 200

if __name__ == "__main__":
    app.run(port=3000, debug=True)
```

{% endcode %}

### How it works

1. **Read the body** → the raw JSON payload sent by Tip4Serv.
2. **Get headers** → `X-Pay-Timestamp` and `X-Pay-Signature` (sent with every request).
3. **Check freshness** → reject if the webhook is older than 5 minutes.
4. **Recalculate signature** → `HMAC-SHA256(timestamp.body, secret)` using your Base64-decoded secret.
5. **Compare signatures** → use `hmac.compare_digest` to avoid timing attacks.
6. **Process event** → decode JSON and handle it (e.g., update your database).
   {% endtab %}

{% tab title="Java + Spring Boot" %}
{% code title="endpoint.java" %}

```java
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
import java.util.Map;

@RestController
public class WebhookController {

    // Secret key (Tip4serv Dashboard -> Webhook -> Custom Webhook)
    private static final String WEBHOOK_SECRET_BASE64 = "y2x3...==";

    @PostMapping("/webhook")
    public ResponseEntity<String> handleWebhook(
            @RequestBody String body,
            @RequestHeader(value = "X-Pay-Timestamp", required = false) String timestamp,
            @RequestHeader(value = "X-Pay-Signature", required = false) String signature
    ) {
        try {
            // 1) Reject if timestamp missing or too old (max 5 min)
            if (timestamp == null || Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(timestamp)) > 300) {
                return ResponseEntity.status(401).body("invalid timestamp");
            }

            // 2) Recalculate expected signature
            String baseString = timestamp + "." + body;
            byte[] secret = Base64.getDecoder().decode(WEBHOOK_SECRET_BASE64);

            Mac hmac = Mac.getInstance("HmacSHA256");
            hmac.init(new SecretKeySpec(secret, "HmacSHA256"));
            String expected = bytesToHex(hmac.doFinal(baseString.getBytes()));

            // 3) Compare signatures
            if (signature == null || !expected.equals(signature)) {
                return ResponseEntity.status(401).body("invalid signature");
            }

            // 4) Webhook is authentic → parse JSON
            com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
            Map<String, Object> event = mapper.readValue(body, Map.class);

            // Example: handle payment.success
            if ("payment.success".equals(event.get("event"))) {
                Map<String, Object> data = (Map<String, Object>) event.get("data");
                String paymentId = String.valueOf(data.get("id"));
                // TODO: update your database
            }

            return ResponseEntity.ok("ok");

        } catch (Exception e) {
            e.printStackTrace();
            return ResponseEntity.status(500).body("error");
        }
    }

    // Helper: convert bytes to hex string
    private static String bytesToHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) sb.append(String.format("%02x", b));
        return sb.toString();
    }
}

```

{% endcode %}

### How it works

* **Read the body** → `@RequestBody String body` contains the raw JSON payload.
* **Get headers** → `X-Pay-Timestamp` and `X-Pay-Signature` are extracted with `@RequestHeader`.
* **Check freshness** → reject if the timestamp is more than 5 minutes old.
* **Recalculate signature** → compute `HMAC-SHA256(timestamp.body, secret)` with your Base64-decoded secret.
* **Compare signatures** → ensure `expected == signature` before processing.
* **Process event** → parse JSON with Jackson (`ObjectMapper`) and act on the `event`.
  {% endtab %}
  {% endtabs %}

### Tips & best practices

* **Respond fast** (under a few seconds). Offload heavy work to a queue/worker.
* Treat optional fields defensively: not every `user` identifier or `custom_fields` will be present.
* Log the **raw payload** (with privacy in mind) to help debugging.
