It was 11:47 PM on Black Friday. I was half-asleep, laptop on my chest, when my phone started buzzing.
Stripe alerts. Payment failures. Then — something worse. Duplicate charge complaints.
We had processed 11 double-charges in under 20 minutes. Users had clicked "Pay" on a slow connection, our server took too long to respond, Stripe retried the webhook, and our handler — which I had written at 2 AM three weeks earlier — had no idempotency check whatsoever.
The next 6 hours were a blur of Slack messages, manual refunds, and a Loom video apology to our most affected customers. We lost two of them for good.
I have thought about that night a lot since then. Not with shame, but with this specific frustration: none of our tests would have caught it. We had good test coverage. We had run the Stripe sandbox. We had done a load test. But none of our tooling could simulate a webhook arriving twice for the same payment event — because Stripe's test environment, by design, doesn't do that.
That's the gap I want to talk about today. And I want to walk through exactly how I'd close it, in every major backend framework.
The actual problem with payment testing
Stripe sandbox is great for testing the happy path. Card declined? Use 4000000000000002. 3DS required? Use 4000002500003155. These are genuinely useful.
But production failures are not usually "wrong card number" failures. They are timing failures. Retry failures. Race conditions. Out-of-order events. The kind of stuff that only happens when your server is under load, when Stripe's retry logic kicks in, when a webhook delivery times out and fires again 30 seconds later.
There are four failure classes I have personally encountered (or seen teammates hit) in production:
Race conditions — same webhook event delivered twice, handler processes both, creates duplicate records or charges.
Delayed webhooks — payment succeeded 10 seconds ago, user is staring at a spinner, your backend has not updated yet, they click again.
3DS abandonment — user closes the authentication modal. Your payment_intent.canceled never fires. Order stays "pending" forever.
Limbo states — card declined at network level but charge attempt still recorded on your end. Two systems disagree on what happened.
The setup: MockCard
MockCard generates test card numbers with real webhook simulation. You give it a scenario and a webhook URL, it generates a card and fires a signed webhook to your actual endpoint — not a mock, not a stub, your real running server.
The key flags:
curl -X POST https://mockcard.io/api/v1/generate \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"brand": "visa",
"scenario": "success",
"webhook_url": "https://your-server.com/webhooks/stripe",
"simulate_race": true
}'
scenario—success,declined,3ds_abandoned,limbo,latencysimulate_race: true— delivers the same webhook event twice, a few hundred ms apartwebhook_url— MockCard POSTs a signed payload here, just like Stripe wouldlog_idin the response — poll/api/v1/delivery-status/{log_id}to confirm delivery
The webhook payload is HMAC-SHA256 signed with your key, so your existing signature verification works unchanged.
Node.js / Express
The handler:
// routes/webhooks.js
const express = require("express");
const crypto = require("crypto");
const { db } = require("../db");
const router = express.Router();
router.post(
"/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
const sig = req.headers["x-mockcard-signature"] ?? req.headers["stripe-signature"];
const expected = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
if (sig !== `sha256=${expected}`) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = JSON.parse(req.body);
const existing = await db.query(
"SELECT id FROM processed_events WHERE event_id = $1",
[event.id]
);
if (existing.rows.length > 0) {
return res.status(200).json({ status: "already_processed" });
}
await db.transaction(async (trx) => {
await trx.query(
"INSERT INTO processed_events (event_id, processed_at) VALUES ($1, NOW())",
[event.id]
);
await handleEvent(event, trx);
});
res.json({ status: "ok" });
}
);
Test 1 — Race condition via MockCard simulate_race
MockCard delivers the same signed event twice within ~300ms — exactly how Stripe behaves when your server times out and it retries. Without the idempotency check above, both deliveries create an order.
test("handles duplicate webhook delivery without double-processing", async () => {
// MockCard fires two deliveries of the same event to our running server
const res = await fetch("https://mockcard.io/api/v1/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.MOCKCARD_KEY}`,
},
body: JSON.stringify({
brand: "visa",
scenario: "success",
webhook_url: "http://localhost:3001/webhooks/stripe",
simulate_race: true, // <-- this is the one that would have saved Black Friday
}),
});
const { log_id } = await res.json();
// Wait for both deliveries to land
await new Promise((r) => setTimeout(r, 2000));
// Poll MockCard delivery log to confirm both attempts were made
const statusRes = await fetch(
`https://mockcard.io/api/v1/delivery-status/${log_id}`,
{ headers: { "Authorization": `Bearer ${process.env.MOCKCARD_KEY}` } }
);
const { attempts } = await statusRes.json();
expect(attempts).toBe(2); // MockCard did fire twice
// But our handler should have created only one order
const orders = await db.query("SELECT * FROM orders");
expect(orders.rows).toHaveLength(1);
});
Test 2 — 3DS abandonment leaves no zombie orders
test("marks order as abandoned when 3DS is never completed", async () => {
// MockCard fires a payment_intent.canceled event — user closed the 3DS modal
await fetch("https://mockcard.io/api/v1/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.MOCKCARD_KEY}`,
},
body: JSON.stringify({
scenario: "3ds_abandoned",
webhook_url: "http://localhost:3001/webhooks/stripe",
}),
});
await new Promise((r) => setTimeout(r, 1000));
const order = await db.query("SELECT status FROM orders ORDER BY created_at DESC LIMIT 1");
// If this fails, you have zombie "pending" orders in production right now
expect(order.rows[0].status).toBe("abandoned");
});
Next.js (App Router)
The handler:
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { db } from "@/lib/db";
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const sig = req.headers.get("x-mockcard-signature") ?? req.headers.get("stripe-signature");
const expected = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET!)
.update(rawBody)
.digest("hex");
if (sig !== `sha256=${expected}`) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const event = JSON.parse(rawBody);
const existing = await db.query(
"SELECT id FROM processed_events WHERE event_id = $1",
[event.id]
);
if (existing.rows.length > 0) {
return NextResponse.json({ status: "already_processed" });
}
await db.transaction(async (trx) => {
await trx.query("INSERT INTO processed_events (event_id) VALUES ($1)", [event.id]);
await processPaymentEvent(event, trx);
});
return NextResponse.json({ status: "ok" });
}
Test 1 — MockCard fires two deliveries, handler stays idempotent
test("idempotent under concurrent duplicate delivery", async () => {
// Ask MockCard to deliver the success event twice to our test server
const mcRes = await fetch("https://mockcard.io/api/v1/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.MOCKCARD_KEY}`,
},
body: JSON.stringify({
scenario: "success",
webhook_url: process.env.TEST_WEBHOOK_URL, // ngrok or similar in CI
simulate_race: true,
}),
});
const { webhook_event, log_id } = await mcRes.json();
// Also fire directly against the handler to test the unit path
const makeReq = () =>
new Request("http://localhost/api/webhooks/stripe", {
method: "POST",
headers: {
"content-type": "application/json",
"x-mockcard-signature": webhook_event.signature,
},
body: JSON.stringify(webhook_event.payload),
});
const [r1, r2] = await Promise.all([POST(makeReq()), POST(makeReq())]);
expect(r1.status).toBe(200);
expect(r2.status).toBe(200);
expect(await db.count("processed_events", { event_id: webhook_event.payload.id })).toBe(1);
});
Test 2 — Limbo state does not leave order pending
test("limbo scenario: order is not left in pending state", async () => {
// MockCard fires payment_intent.payment_failed with network-level decline
const mcRes = await fetch("https://mockcard.io/api/v1/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.MOCKCARD_KEY}`,
},
body: JSON.stringify({
scenario: "limbo",
webhook_url: process.env.TEST_WEBHOOK_URL,
}),
});
const { webhook_event } = await mcRes.json();
const req = new Request("http://localhost/api/webhooks/stripe", {
method: "POST",
headers: {
"content-type": "application/json",
"x-mockcard-signature": webhook_event.signature,
},
body: JSON.stringify(webhook_event.payload),
});
await POST(req);
const order = await db.findLatestOrder();
// Must not be pending — if it is, the network decline never reached your handler
expect(order.status).not.toBe("pending");
expect(["failed", "timed_out", "declined"]).toContain(order.status);
});
Laravel (PHP)
The handler:
<?php
class WebhookController extends Controller
{
public function handle(Request $request)
{
$payload = $request->getContent();
$sig = $request->header("X-MockCard-Signature")
?? $request->header("Stripe-Signature");
$expected = "sha256=" . hash_hmac(
"sha256", $payload, config("services.webhook_secret")
);
if (!hash_equals($expected, $sig ?? "")) {
return response()->json(["error" => "Invalid signature"], 401);
}
$event = json_decode($payload, true);
$eventId = $event["id"];
$alreadyProcessed = DB::table("processed_events")
->where("event_id", $eventId)->exists();
if ($alreadyProcessed) {
return response()->json(["status" => "already_processed"]);
}
DB::transaction(function () use ($event, $eventId) {
DB::table("processed_events")->insert([
"event_id" => $eventId, "processed_at" => now(),
]);
$this->processEvent($event);
});
return response()->json(["status" => "ok"]);
}
}
Test 1 — MockCard simulate_race, one order created
public function test_duplicate_delivery_creates_only_one_order(): void
{
// MockCard will POST the same signed event to our app twice
$mc = Http::withToken(config("services.mockcard_key"))
->post("https://mockcard.io/api/v1/generate", [
"brand" => "visa",
"scenario" => "success",
"webhook_url" => config("services.test_webhook_url"),
"simulate_race" => true,
])->json();
// Wait for MockCard to deliver both attempts
sleep(2);
// MockCard logged both delivery attempts
$status = Http::withToken(config("services.mockcard_key"))
->get("https://mockcard.io/api/v1/delivery-status/{$mc["log_id"]}")
->json();
$this->assertEquals(2, $status["attempts"]);
// But only one order and one processed event should exist
$this->assertDatabaseCount("processed_events", 1);
$this->assertDatabaseCount("orders", 1);
}
Test 2 — Out-of-order events: late failure does not overwrite paid order
public function test_late_failure_event_does_not_downgrade_paid_order(): void
{
// Step 1: success event arrives first — order becomes "paid"
$success = Http::withToken(config("services.mockcard_key"))
->post("https://mockcard.io/api/v1/generate", [
"scenario" => "success",
"webhook_url" => config("services.test_webhook_url"),
])->json("webhook_event");
$this->postJson("/webhooks/stripe", $success["payload"],
["X-MockCard-Signature" => $success["signature"]]
)->assertStatus(200);
$this->assertDatabaseHas("orders", ["status" => "paid"]);
// Step 2: stale declined event arrives late (Stripe retry from earlier attempt)
// MockCard generates a declined payload — we reuse the same payment_intent id
$declined = Http::withToken(config("services.mockcard_key"))
->post("https://mockcard.io/api/v1/generate", [
"scenario" => "declined",
])->json("webhook_event");
// Inject the same payment_intent id to simulate out-of-order delivery
$declined["payload"]["data"]["object"]["id"] =
$success["payload"]["data"]["object"]["id"];
$this->postJson("/webhooks/stripe", $declined["payload"],
["X-MockCard-Signature" => $declined["signature"]]
)->assertStatus(200);
// Order must still be paid — late failure must not overwrite success
$this->assertDatabaseHas("orders", ["status" => "paid"]);
$this->assertDatabaseMissing("orders", ["status" => "failed"]);
}
Ruby on Rails
The handler:
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :verify_signature
def stripe
event = JSON.parse(request.body.read)
event_id = event["id"]
if ProcessedEvent.exists?(event_id: event_id)
render json: { status: "already_processed" } and return
end
ActiveRecord::Base.transaction do
ProcessedEvent.create!(event_id: event_id)
process_event(event)
end
render json: { status: "ok" }
end
private
def verify_signature
payload = request.body.read.tap { request.body.rewind }
sig = request.headers["X-MockCard-Signature"] || request.headers["Stripe-Signature"]
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("WEBHOOK_SECRET"), payload)
render json: { error: "Invalid signature" }, status: :unauthorized unless
ActiveSupport::SecurityUtils.secure_compare(expected, sig.to_s)
end
def process_event(event)
case event["type"]
when "payment_intent.succeeded" then PaymentService.fulfill(event.dig("data", "object"))
when "payment_intent.payment_failed" then PaymentService.mark_failed(event.dig("data", "object"))
when "payment_intent.canceled" then OrderService.cancel(event.dig("data", "object", "id"))
end
end
end
Test 1 — MockCard race condition, threaded delivery
RSpec.describe "Webhook idempotency", type: :request do
it "creates one order even when MockCard delivers the event twice" do
# MockCard fires two deliveries to our test endpoint
mc = HTTParty.post(
"https://mockcard.io/api/v1/generate",
headers: {
"Content-Type" => "application/json",
"Authorization" => "Bearer #{ENV["MOCKCARD_KEY"]}",
},
body: {
brand: "visa",
scenario: "success",
webhook_url: ENV["TEST_WEBHOOK_URL"],
simulate_race: true,
}.to_json
)
data = JSON.parse(mc.body)
# Give MockCard time to fire both deliveries
sleep 2
# Confirm MockCard actually made two delivery attempts
status = HTTParty.get(
"https://mockcard.io/api/v1/delivery-status/#{data["log_id"]}",
headers: { "Authorization" => "Bearer #{ENV["MOCKCARD_KEY"]}" }
)
expect(JSON.parse(status.body)["attempts"]).to eq(2)
# Only one order must exist
expect(Order.count).to eq(1)
expect(ProcessedEvent.count).to eq(1)
end
end
Test 2 — 3DS abandonment: no zombie pending orders
RSpec.describe "3DS abandonment", type: :request do
it "marks the order abandoned when the user closes the 3DS modal" do
# MockCard fires payment_intent.canceled — user bailed on authentication
mc = HTTParty.post(
"https://mockcard.io/api/v1/generate",
headers: {
"Content-Type" => "application/json",
"Authorization" => "Bearer #{ENV["MOCKCARD_KEY"]}",
},
body: {
scenario: "3ds_abandoned",
webhook_url: ENV["TEST_WEBHOOK_URL"],
}.to_json
)
sleep 1
order = Order.last
# If this fails, you have zombie "pending" rows in production right now
expect(order.status).not_to eq("pending")
expect(%w[abandoned canceled]).to include(order.status)
end
end
Java (Spring Boot)
The handler:
@PostMapping(value = "/stripe", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, String>> handleWebhook(
@RequestBody String rawBody,
@RequestHeader(value = "X-MockCard-Signature", required = false) String mockSig,
@RequestHeader(value = "Stripe-Signature", required = false) String stripeSig
) throws Exception {
String sig = mockSig != null ? mockSig : stripeSig;
if (!verifySignature(rawBody, sig)) {
return ResponseEntity.status(401).body(Map.of("error", "Invalid signature"));
}
JsonNode event = new ObjectMapper().readTree(rawBody);
String eventId = event.get("id").asText();
try {
// DB unique constraint is the real lock — no check-then-insert
processedEventRepository.save(new ProcessedEvent(eventId));
} catch (DataIntegrityViolationException e) {
return ResponseEntity.ok(Map.of("status", "already_processed"));
}
paymentService.handle(event);
return ResponseEntity.ok(Map.of("status", "ok"));
}
Test 1 — MockCard fires two deliveries concurrently
@Test
void duplicateDeliveryFromMockCardCreatesOneOrderOnly() throws Exception {
// Tell MockCard to fire the success event twice to our running server
HttpHeaders mcHeaders = new HttpHeaders();
mcHeaders.setContentType(MediaType.APPLICATION_JSON);
mcHeaders.setBearerAuth(System.getenv("MOCKCARD_KEY"));
String mcBody = """
{
"brand": "visa",
"scenario": "success",
"webhook_url": "%s",
"simulate_race": true
}
""".formatted(System.getenv("TEST_WEBHOOK_URL"));
ResponseEntity<Map> mcResp = restTemplate.exchange(
"https://mockcard.io/api/v1/generate",
HttpMethod.POST,
new HttpEntity<>(mcBody, mcHeaders),
Map.class
);
String logId = (String) mcResp.getBody().get("log_id");
// Wait for MockCard to deliver both attempts
Thread.sleep(2000);
// Verify MockCard made 2 delivery attempts
ResponseEntity<Map> statusResp = restTemplate.exchange(
"https://mockcard.io/api/v1/delivery-status/" + logId,
HttpMethod.GET,
new HttpEntity<>(mcHeaders),
Map.class
);
assertEquals(2, statusResp.getBody().get("attempts"));
// Only one processed event and one order should exist
assertEquals(1, processedEventRepository.count());
assertEquals(1, orderRepository.count());
}
Test 2 — Limbo state: no paid order created
@Test
void limboScenarioFromMockCardDoesNotCreatePaidOrder() throws Exception {
HttpHeaders mcHeaders = new HttpHeaders();
mcHeaders.setContentType(MediaType.APPLICATION_JSON);
mcHeaders.setBearerAuth(System.getenv("MOCKCARD_KEY"));
// MockCard fires payment_intent.payment_failed with network-level decline reason
String mcBody = """
{"scenario": "limbo", "webhook_url": "%s"}
""".formatted(System.getenv("TEST_WEBHOOK_URL"));
restTemplate.exchange(
"https://mockcard.io/api/v1/generate",
HttpMethod.POST,
new HttpEntity<>(mcBody, mcHeaders),
Map.class
);
Thread.sleep(1000);
// No paid orders should exist
assertEquals(0, orderRepository.countByStatus("paid"));
// Must be in a terminal failure state
Order order = orderRepository.findAll().get(0);
assertTrue(
List.of("failed", "declined").contains(order.getStatus()),
"Expected failed/declined, got: " + order.getStatus()
);
}
PHP (plain)
The handler:
<?php
$rawBody = file_get_contents("php://input");
$sig = $_SERVER["HTTP_X_MOCKCARD_SIGNATURE"]
?? $_SERVER["HTTP_STRIPE_SIGNATURE"]
?? "";
$expected = "sha256=" . hash_hmac("sha256", $rawBody, getenv("WEBHOOK_SECRET"));
if (!hash_equals($expected, $sig)) {
http_response_code(401);
echo json_encode(["error" => "Invalid signature"]);
exit;
}
$event = json_decode($rawBody, true);
$pdo = new PDO(getenv("DATABASE_URL"));
$stmt = $pdo->prepare(
"INSERT INTO processed_events (event_id, processed_at)
VALUES (?, NOW())
ON CONFLICT (event_id) DO NOTHING"
);
$stmt->execute([$event["id"]]);
if ($stmt->rowCount() === 0) {
echo json_encode(["status" => "already_processed"]);
exit;
}
processEvent($event, $pdo);
echo json_encode(["status" => "ok"]);
Test 1 — MockCard race condition, PHPUnit
public function testMockCardRaceConditionCreatesOneOrder(): void
{
// Ask MockCard to deliver the same event twice to our test server
$ch = curl_init("https://mockcard.io/api/v1/generate");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Authorization: Bearer " . getenv("MOCKCARD_KEY"),
],
CURLOPT_POSTFIELDS => json_encode([
"brand" => "visa",
"scenario" => "success",
"webhook_url" => getenv("TEST_WEBHOOK_URL"),
"simulate_race" => true,
]),
]);
$mc = json_decode(curl_exec($ch), true);
$logId = $mc["log_id"];
curl_close($ch);
// Give MockCard time to fire both deliveries
sleep(2);
// Confirm MockCard made two attempts
$ch = curl_init("https://mockcard.io/api/v1/delivery-status/{$logId}");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("MOCKCARD_KEY")],
]);
$status = json_decode(curl_exec($ch), true);
curl_close($ch);
$this->assertEquals(2, $status["attempts"]);
// Only one order and one processed_event row must exist
$count = $this->pdo->query("SELECT COUNT(*) FROM processed_events")->fetchColumn();
$this->assertEquals(1, $count);
$orders = $this->pdo->query("SELECT COUNT(*) FROM orders")->fetchColumn();
$this->assertEquals(1, $orders);
}
Test 2 — Tampered signature is rejected before any DB write
public function testTamperedSignatureIsRejectedWithoutSideEffects(): void
{
// Get a valid payload from MockCard but swap in a bad signature
$ch = curl_init("https://mockcard.io/api/v1/generate");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Authorization: Bearer " . getenv("MOCKCARD_KEY"),
],
CURLOPT_POSTFIELDS => json_encode(["scenario" => "success"]),
]);
$mc = json_decode(curl_exec($ch), true);
curl_close($ch);
$payload = json_encode($mc["webhook_event"]["payload"]);
$badSignature = "sha256=" . str_repeat("a", 64); // forged
$response = $this->postWebhook($payload, $badSignature);
// Must be rejected — 401, no DB writes
$this->assertEquals(401, $response->getStatusCode());
$count = $this->pdo->query("SELECT COUNT(*) FROM processed_events")->fetchColumn();
$this->assertEquals(0, $count);
}
CI/CD: every PR, automatically
# .github/workflows/payment-chaos.yml
name: Payment Chaos Tests
on: [push, pull_request]
jobs:
webhook-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
env:
DATABASE_URL: postgresql://postgres:test@localhost/testdb
WEBHOOK_SECRET: test-secret
MOCKCARD_KEY: ${{ secrets.MOCKCARD_KEY }}
TEST_WEBHOOK_URL: ${{ secrets.TEST_WEBHOOK_URL }} # ngrok tunnel or similar
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci && npm run db:migrate
- run: npm start &
- run: sleep 3
- run: npm run test:chaos
The TEST_WEBHOOK_URL is a stable tunnel (ngrok, Cloudflare, or your staging environment) that MockCard can reach to deliver webhooks during CI. MockCard's log_id delivery status polling lets you assert that the webhook was actually delivered — no more tests that pass because the webhook silently failed to arrive.
What Pro unlocks
The free tier covers the happy path and basic race condition testing. Pro is where the CI workflow becomes practical:
Delivery logs with log_id — every /generate call returns a log_id. Poll /api/v1/delivery-status/{log_id} to assert exactly how many delivery attempts were made and whether they succeeded. Without this, you are guessing.
simulate_race flag — free tier generates cards. Pro delivers the same event twice and lets you prove your handler is idempotent. This is the flag that would have stopped the Black Friday incident.
All 4 chaos modes — 3DS abandonment and limbo states are Pro. These are the scenarios that produce no error, just silent broken state. They are the hardest to discover in production and the easiest to prevent with a one-line test.
No rate-limit anxiety — running chaos tests on every PR commit adds up fast. Pro gives you the headroom to not think about it.
The thing I actually learned from Black Friday
The double-charge incident was not a code quality failure. The code was fine. It was a testing coverage failure — we had not thought to test "what if this exact function is called twice, concurrently, with the same inputs?"
MockCard makes that question a two-minute test instead of a two-hour investigation. You call the API, point it at your server, and it either breaks you or it doesn't. Either outcome is useful.
If you've shipped a payment system without this, go add it this week. Good luck. Ship boring payment code.