# Guide to Tebex Webhooks: Features and Best Practices

Tebex webhooks notify your systems about monetization activity in your store: a payment completes, a payment is refunded or disputed, a recurring payment renews. If you're building on Tebex, webhooks are how you react to purchases without polling.

This guide covers how Tebex webhooks work, the events you'll handle, its two-step signature (get this exactly right), the validation ping, IP allowlisting, and the best practices for production.

## What are Tebex webhooks?

Tebex webhooks are HTTP POSTs delivered to endpoints you configure per store. Each carries an `X-Signature` header, and the signature construction is unusual: it's a two-step hash, not a plain HMAC of the body. Tebex also allowlists its source IPs and sends a validation ping you must answer to activate an endpoint.

## Tebex webhook features

| Feature | Details |
| --- | --- |
| Configuration | Creator Panel > Developers > Webhooks > Endpoints (per-endpoint secret + event types) |
| Signature header | `X-Signature` (hex) |
| Signature scheme | `HMAC-SHA256(sha256(rawBody), secret)`, hex (two-step) |
| Source IPs | `18.209.80.3`, `54.87.231.232` (docs suggest returning 404 for others) |
| Setup ping | `validation.webhook`: respond 200 with `{"id": "<webhook id>"}` |
| Acknowledgement | Always respond 2XX; any other status triggers retries |
| SDK | None |

## Common events

Tebex event types are dotted:

| Event | Fires when |
| --- | --- |
| `payment.completed` | A payment completes |
| `payment.declined` | A payment is declined |
| `payment.refunded` | A payment is refunded |
| `payment.dispute.opened` | A dispute is opened (`.won` / `.lost` / `.closed` follow) |
| `recurring-payment.renewed` | A recurring payment renews |
| `validation.webhook` | The setup ping (respond with the webhook id) |

Recurring events also include `recurring-payment.started` / `.ended` and `recurring-payment.cancellation.requested` / `.aborted`. Select event types per endpoint in the Creator Panel.

## Setting up Tebex webhooks

Configure endpoints under Creator Panel > Developers > Webhooks > Endpoints, set the secret, and select event types. On setup, Tebex sends a `validation.webhook` event; respond 200 with `{"id": "<the webhook id>"}` echoing the id, or the endpoint won't activate. Optionally restrict the endpoint to Tebex's source IPs (returning 404 for others).

## Securing Tebex webhooks

The signature is two-step: first SHA256-hash the raw body, then HMAC-SHA256 that hash with your webhook secret, hex-encoded. It is not a plain HMAC of the body. Use the raw body (JSON body-parsers break the signature).

```javascript
const crypto = require("crypto");

const SECRET = process.env.TEBEX_WEBHOOK_SECRET;
const ALLOWED_IPS = new Set(["18.209.80.3", "54.87.231.232"]);

function verify(rawBody, signature) {
  // Two-step: HMAC-SHA256( sha256(body), secret )
  const bodyHash = crypto.createHash("sha256").update(rawBody).digest("hex");
  const expected = crypto.createHmac("sha256", SECRET).update(bodyHash).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!ALLOWED_IPS.has(req.ip)) return res.sendStatus(404);
  if (!verify(req.body, req.headers["x-signature"])) return res.sendStatus(401);

  const payload = JSON.parse(req.body);

  // Setup ping: echo the webhook id
  if (payload.type === "validation.webhook") {
    return res.status(200).json({ id: payload.id });
  }

  res.sendStatus(200); // always respond 2XX
  processQueue.add(payload); // process asynchronously
});

```

The same two-step check in Python (matching Tebex's PHP reference):

```python
import hashlib
import hmac
import os

SECRET = os.environ["TEBEX_WEBHOOK_SECRET"].encode()

def verify(raw_body: bytes, signature: str) -> bool:
    body_hash = hashlib.sha256(raw_body).hexdigest()
    expected = hmac.new(SECRET, body_hash.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")

```

## Tebex webhook limitations and pain points

### The two-step signature

The Problem: The signature is `HMAC-SHA256(sha256(body), secret)`, you HMAC the SHA256 hash of the body, not the body. Almost every "obvious" implementation (a plain HMAC of the body) fails.

Why It Happens: Tebex's documented construction hashes the body first, then HMACs the hash.

Workarounds:

* SHA256 the raw body, then HMAC-SHA256 that hash with the secret, hex-encode, and compare.

How Hookdeck Can Help: Hookdeck verifies the Tebex signature at the edge, so your application receives pre-verified events without reproducing the two-step hash.

### The validation ping gates activation

The Problem: The endpoint won't activate until you answer the `validation.webhook` ping with `{"id": "<webhook id>"}`. Miss it and you receive nothing.

Why It Happens: Tebex validates the endpoint before delivering events.

Workarounds:

* Detect `validation.webhook` and echo the `id` with a 200.

How Hookdeck Can Help: Hookdeck can answer the validation ping and forward verified events, so activation isn't a manual concern.

### IP allowlisting and raw body

The Problem: Tebex delivers only from two IPs (and suggests returning 404 to others), and the signature is over the raw body, so a JSON parser upstream breaks verification.

Why It Happens: Tebex uses a fixed egress and signs the raw body.

Workarounds:

* Allowlist the two IPs, and capture the raw body before parsing.

How Hookdeck Can Help: Hookdeck verifies against the bytes as received and gives you a dedicated ingestion URL, so IP and raw-body handling are managed for you.

### Retries on non-2XX

The Problem: Any non-2XX response triggers retries, so a handler that errors out gets repeated deliveries.

Why It Happens: Tebex treats non-2XX as "not delivered".

Workarounds:

* Return 2XX once verified and enqueued; handle processing errors internally, and dedupe.

How Hookdeck Can Help: Hookdeck returns success to Tebex and retries to your downstream on its own schedule, so a transient error doesn't cause a retry storm. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Best practices

### Verify the two-step signature over the raw body

SHA256 the raw body, HMAC-SHA256 that hash with your secret, hex-encode, and compare in constant time.

### Answer the validation ping

Echo `{"id": "<webhook id>"}` with a 200 to activate the endpoint.

### Allowlist the source IPs

Restrict deliveries to `18.209.80.3` and `54.87.231.232`.

### Always return 2XX, process asynchronously

Return 2XX once verified and defer work to a queue; handle failures internally. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Tebex webhooks use a distinctive two-step signature, `HMAC-SHA256(sha256(rawBody), secret)`, over the raw body, plus a `validation.webhook` ping you answer with the webhook id and a two-IP allowlist. Get the two-step hash right, activate via the ping, allowlist the IPs, and always return 2XX.

[Hookdeck](https://hookdeck.com) verifies the signature, deduplicates, and durably queues every event at the edge, so your app only ever processes verified, unique payment events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Tebex webhooks reliably in minutes.