All posts
webhookspayment testingngroknode.jspythonphp

Testing Payment Webhooks Locally with MockCard.io

March 20, 20266 min read

Webhooks are the part of a payment integration that nobody tests properly until something breaks in production. The card charge succeeds, the webhook fails silently, and your order management system never finds out. MockCard.io was built with this exact problem in mind — every scenario fires a signed webhook event so you can build and test your handler before you ever touch a real payment gateway.

This guide covers everything: what the webhook payload looks like, how to verify the signature, how to receive it locally with ngrok, and how to handle every scenario MockCard.io can throw at you.


What MockCard.io Sends and When

Every call to POST /api/v1/generate triggers two things simultaneously:

  1. An HTTP response with the generated card details (or error)
  2. An HMAC-signed POST to your webhook_url

The webhook fires as a background task — your API response arrives first, and the webhook delivery follows within a second. This mirrors exactly how production gateways like Stripe work, so you are building against realistic timing.

The two headers on every webhook request:

Header Value
X-MockCard-Event e.g. payment_intent.succeeded
X-MockCard-Signature sha256=<64-char hex>
Content-Type application/json

The Webhook Payload

Every event follows the same envelope structure regardless of scenario:

{
  "id": "evt_mock_765bb955a3b5",
  "object": "event",
  "type": "payment_intent.succeeded",
  "created": 1773577425,
  "livemode": false,
  "data": {
    "object": {
      "id": "pi_mock_05b6de1ab8ba",
      "object": "payment_intent",
      "amount": 1999,
      "currency": "usd",
      "status": "succeeded",
      "payment_method_details": {
        "type": "card",
        "card": {
          "brand": "visa",
          "last4": "5931",
          "exp_month": "06",
          "exp_year": "2029"
        }
      }
    }
  }
}

For failure scenarios the type is payment_intent.payment_failed and the data.object includes an error block:

{
  "type": "payment_intent.payment_failed",
  "data": {
    "object": {
      "status": "requires_payment_method",
      "last_payment_error": {
        "code": "insufficient_funds",
        "decline_code": "insufficient_funds",
        "message": "Your card has insufficient funds."
      }
    }
  }
}

Verifying the Signature

Never process a webhook without verifying the signature first. The signature is sha256= followed by the HMAC-SHA256 hex digest of the raw request body using your webhook secret.

Node.js / Express

const crypto = require("crypto");

function verifySignature(rawBody, signatureHeader, secret) {
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(rawBody) // Buffer, not parsed JSON
    .digest("hex");

  // timing-safe comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signatureHeader)
  );
}

// Express handler — must use express.raw() middleware, NOT express.json()
app.post("/webhook/mockcard", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.headers["x-mockcard-signature"];

  if (!verifySignature(req.body, sig, process.env.MOCKCARD_WEBHOOK_SECRET)) {
    return res.status(400).send("Invalid signature");
  }

  const event = JSON.parse(req.body.toString());

  switch (event.type) {
    case "payment_intent.succeeded":
      fulfillOrder(event.data.object);
      break;
    case "payment_intent.payment_failed":
      notifyCustomer(event.data.object);
      break;
  }

  res.json({ received: true });
});

Python / FastAPI

import hmac, hashlib
from fastapi import FastAPI, Request, HTTPException, Header

app = FastAPI()

def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

@app.post("/webhook/mockcard")
async def handle_webhook(
    request: Request,
    x_mockcard_signature: str = Header(...),
):
    raw_body = await request.body()

    if not verify_signature(raw_body, x_mockcard_signature, MOCKCARD_WEBHOOK_SECRET):
        raise HTTPException(status_code=400, detail="Invalid signature")

    event = await request.json()

    if event["type"] == "payment_intent.succeeded":
        await fulfill_order(event["data"]["object"])
    elif event["type"] == "payment_intent.payment_failed":
        await notify_customer(event["data"]["object"])

    return {"received": True}

Laravel / PHP

Route::post('/webhook/mockcard', function (Request $request) {
    $payload = $request->getContent();
    $signature = $request->header('X-MockCard-Signature');
    $secret = env('MOCKCARD_WEBHOOK_SECRET');

    $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);

    if (!hash_equals($expected, $signature)) {
        abort(400, 'Invalid signature');
    }

    $event = json_decode($payload, true);

    match ($event['type']) {
        'payment_intent.succeeded' => fulfillOrder($event['data']['object']),
        'payment_intent.payment_failed' => notifyCustomer($event['data']['object']),
        default => null,
    };

    return response()->json(['received' => true]);
});

The critical detail in all three examples: read the raw bytes before parsing JSON. Once you call JSON.parse() or equivalent, whitespace differences can cause the computed digest to not match.


Receiving Webhooks Locally

Your localhost is not reachable from the internet, so you need a tunnel to test webhook delivery during development.

Option 1 — ngrok (most popular)

# Install ngrok, then:
ngrok http 3000

# Copy the https URL it prints, e.g.:
# https://a1b2c3d4.ngrok-free.app

Use that URL as your webhook_url:

curl -s -X POST https://mockcard.io/api/v1/generate \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: YOUR_KEY" \
  -d '{
    "brand":       "visa",
    "scenario":    "success",
    "amount":      2000,
    "currency":    "usd",
    "webhook_url": "https://a1b2c3d4.ngrok-free.app/webhook/mockcard"
  }'

The webhook arrives at your local Express/FastAPI/Laravel server within a second.

Option 2 — webhook.site (no install)

Go to webhook.site — you get a unique URL instantly. Paste it as your webhook_url. Every delivery shows you the full headers and body. Great for quickly checking the payload shape without writing any code.

Option 3 — Cloudflare Tunnel (permanent, free)

cloudflared tunnel --url http://localhost:3000

Gives you a stable *.trycloudflare.com URL that persists across restarts.


Testing Every Event Type

Here is a one-liner for each event so you can exercise your handler systematically:

payment_intent.succeeded

curl -s -X POST https://mockcard.io/api/v1/generate \
  -H "X-Api-Key: YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"scenario":"success","webhook_url":"YOUR_URL"}' | jq .webhook_event.type

payment_intent.payment_failed — insufficient funds

curl -s -X POST https://mockcard.io/api/v1/generate \
  -H "X-Api-Key: YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"scenario":"insufficient_funds","webhook_url":"YOUR_URL"}' | jq .error.webhook_event.type

payment_intent.payment_failed — hard decline

curl -s -X POST https://mockcard.io/api/v1/generate \
  -H "X-Api-Key: YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"scenario":"do_not_honor","webhook_url":"YOUR_URL"}' | jq .error.webhook_event.type

payment_intent.payment_failed — network timeout

curl -s -X POST https://mockcard.io/api/v1/generate \
  -H "X-Api-Key: YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"scenario":"network_timeout","webhook_url":"YOUR_URL"}' | jq .error.webhook_event.type

Handling Webhooks Correctly in Production

A few patterns that prevent the most common bugs:

1. Return 200 immediately, process asynchronously

Your handler should write the event to a queue and return {"received": true} within a second. If your handler takes too long and the delivery times out, the gateway may retry and you process the event twice.

2. Make your handler idempotent

Check the event id before acting on it. Store processed event IDs in your database and skip duplicates:

const exists = await db.query(
  "SELECT 1 FROM processed_events WHERE event_id = $1", [event.id]
);
if (exists.rows.length > 0) return res.json({ received: true });

await db.query("INSERT INTO processed_events (event_id) VALUES ($1)", [event.id]);
// now safe to process

3. Never trust the payload alone — always verify the signature

MockCard.io sends a verified payload, but your production webhook handler should never skip signature verification regardless of the source. The pattern above is what you want to ship.

4. Log everything

console.log({
  event_id:   event.id,
  event_type: event.type,
  pi_id:      event.data.object.id,
  amount:     event.data.object.amount,
  status:     event.data.object.status,
  received_at: new Date().toISOString(),
});

Retrying a Failed Delivery

If your receiver was down when MockCard.io delivered the webhook, you can replay it using the retry endpoint:

curl -s -X POST https://mockcard.io/api/v1/webhook/retry \
  -H "Content-Type: application/json" \
  -H "X-Api-Key: YOUR_KEY" \
  -d '{
    "url":     "https://your-app.com/webhook/mockcard",
    "payload": { ...the original webhook_event object... }
  }'

This re-signs the payload with the same key and POSTs it to the URL you specify — useful for local debugging after a tunnel restart.


Checklist Before Going Live

  • Signature verified using timingSafeEqual / hash_equals (not ===)
  • Raw body bytes used for HMAC — not re-serialised JSON
  • Handler returns 200 within 1–2 seconds
  • Idempotency check on event.id
  • All event types handled (at minimum: succeeded and payment_failed)
  • Webhook secret stored in environment variable, never in source code
  • Full event logged with timestamp before processing

What to Read Next