# 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.


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.tip4serv.com/webhooks-developers-only/handle-webhook.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
