# How I test webhooks before trusting them

A practical step-by-step guide to webhook payloads, signatures, replay checks, and using the Webhook Tester for Stripe, GitHub, and Shopify-style webhooks.

- Date: 2026-09-27
- URL: https://ilham.dev/posts/how-to-test-webhooks-safely/
- Markdown: https://ilham.dev/posts/how-to-test-webhooks-safely/index.md
- Tags: webhooks, api, security, tools
- Reading time: 5 min


A webhook is a simple idea: one system sends an HTTP request to another system when
something happens.

For example:

- Stripe sends a webhook when a payment succeeds;
- GitHub sends a webhook when someone pushes code;
- Shopify sends a webhook when an order is created.

It sounds like a normal API call, but webhooks need extra care because they come
from outside your app. You should not trust a webhook only because it reached your
server.

The [Webhook Tester](/tools/webhook-tester/) helps you build a sample payload, sign
it, verify it, and understand what your backend should check.

## The simple mental model

Think of a webhook like a delivery package.

The payload is the package content:

```json
{
  "event": "payment.succeeded",
  "amount": 50000
}
```

The signature is the tamper seal:

```text
t=1790467200,v1=...
```

Your server should ask two questions:

1. Did this package really come from the service I trust?
2. Was the package changed on the way?

That is what signature verification is for.

## Step 1: know the webhook provider

Different providers sign webhooks differently. Do not assume all webhook signatures
work the same way.

The [Webhook Tester](/tools/webhook-tester/) supports common styles such as:

- Stripe-style signatures;
- GitHub-style signatures;
- Shopify-style signatures.

Pick the provider style that matches the service you are integrating with.

This matters because the header names and signing formats are different.

## Step 2: prepare a sample payload

Start with a small JSON payload. For example:

```json
{
  "id": "evt_test_123",
  "type": "payment.succeeded",
  "data": {
    "amount": 50000,
    "currency": "IDR"
  }
}
```

Keep the first test simple. If the small payload works, move to a real provider
payload later.

The payload should be treated as raw text when signing. This is important. Changing
spaces, line breaks, or key order can change the signature result depending on the
provider.

## Step 3: choose or generate a secret

Webhook signatures usually use a shared secret.

For testing, you can use something obvious:

```text
whsec_test_secret
```

For production, use a long random secret from the provider dashboard or a secure
secret generator. Do not hard-code production secrets in frontend code. Webhook
secrets belong on the server.

## Step 4: generate the signature

In the [Webhook Tester](/tools/webhook-tester/), choose the provider style, paste
the payload, and enter the secret.

The tool builds the signature header that your backend should expect.

For Stripe-style webhooks, the header may look like:

```http
Stripe-Signature: t=1790467200,v1=<signature>
```

For GitHub-style webhooks, it may look like:

```http
X-Hub-Signature-256: sha256=<signature>
```

For Shopify-style webhooks, it may look like:

```http
X-Shopify-Hmac-Sha256: <signature>
```

The exact header matters. If your backend reads the wrong header, verification will
fail even if the secret is correct.

## Step 5: verify the signature

Now use the verification part of the tool with the same payload, secret, and header.

If verification passes, it means:

- the payload matches the signature;
- the secret is correct;
- the selected provider format is correct.

If verification fails, check these first:

- did you choose the correct provider style?
- did you paste the same payload text?
- did the payload get reformatted?
- did you use the right secret?
- did you include the right timestamp or header prefix?

Most webhook signature bugs are caused by signing one version of the payload and
verifying another version.

## Step 6: understand raw body vs parsed JSON

This is one of the most common webhook mistakes.

Many web frameworks parse JSON automatically. That is convenient for normal API
routes, but it can break webhook verification.

For signature checking, providers usually expect you to verify the **raw request
body**, exactly as received.

This can fail:

```text
receive raw JSON -> parse it -> stringify it again -> verify
```

because the final string may not be byte-for-byte identical to the original body.

The safer flow is:

```text
receive raw body -> verify signature -> parse JSON -> handle event
```

Verify first, parse after.

## Step 7: check the timestamp

Some providers include a timestamp in the signature header. Stripe-style signatures,
for example, include `t=`.

The timestamp helps prevent replay attacks.

A replay attack is when someone captures a real webhook and sends it again later.
The signature may still be valid, but the request is old.

A backend usually checks that the timestamp is recent, for example within five
minutes.

So your server should check:

1. Is the signature valid?
2. Is the timestamp recent enough?
3. Has this event id already been processed?

## Step 8: make the event handler idempotent

“Idempotent” means safe to run more than once.

Webhook providers may retry delivery if your server is slow or returns an error.
That means your app may receive the same event more than once.

If the event is:

```json
{
  "id": "evt_test_123",
  "type": "payment.succeeded"
}
```

store `evt_test_123` after processing it. If the same event arrives again, skip the
side effect.

This prevents bugs like:

- marking the same invoice paid twice;
- sending two emails;
- adding duplicate credits;
- creating duplicate orders.

## Step 9: send a test request carefully

The Webhook Tester can help prepare a test request. When sending it to your local or
staging endpoint, check:

- the URL is correct;
- the method is `POST`;
- the `Content-Type` is correct, usually `application/json`;
- the signature header is included;
- your backend reads the raw body;
- your backend logs enough detail to debug failures.

Do not start by testing against production. Use local development or staging first.

## Step 10: decide what to log

Webhook logs are helpful, but be careful not to log secrets.

Good things to log:

- provider name;
- event id;
- event type;
- timestamp;
- verification result;
- reason for rejection.

Avoid logging:

- webhook secret;
- full payment details if not needed;
- sensitive customer data;
- authentication tokens.

A good rejection log might say:

```text
webhook rejected: invalid signature, provider=stripe, event_id=evt_test_123
```

That is useful without leaking the secret.

## My webhook checklist

When testing a webhook, I go through this checklist:

1. Choose the correct provider format.
2. Use a small known JSON payload first.
3. Generate a signature with the test secret.
4. Verify the same payload and header.
5. Make sure the backend verifies the raw request body.
6. Check timestamp tolerance if the provider uses timestamps.
7. Store event ids to avoid processing duplicates.
8. Test locally or in staging before production.
9. Log verification failures without logging secrets.

The key lesson is simple:

> A webhook is not trusted because it arrived. It is trusted only after the
> signature, timestamp, and event id checks pass.

The [Webhook Tester](/tools/webhook-tester/) gives you a safe place to practice that
flow before wiring it into a real backend.
