WireMock tutorial: stubbing a payment processor with WireMock 3.13.2
This WireMock tutorial stubs a payment processor with WireMock 3.13.2 JSON (JavaScript Object Notation) mappings: an approval, a soft decline, a timeout after approval using fixedDelayMilliseconds, an authorise-then-capture state machine built from scenarios, and a capture webhook. Every mapping was re-run against WireMock 3.13.2 on 22 September 2026 with synthetic tokens.
Tomas Reyes, Test automation architect, financial platformsPublished Updated
Which WireMock version this tutorial uses
We checked the WireMock release list on 22 September 2026. The newest stable release is 3.13.2, published on 14 November 2025. The 4.x line is still a pre-release: 4.0.0-beta.38 was published on 2 July 2026 and is marked "Pre-release" on GitHub. Every mapping below targets 3.13.2 standalone.
Three WireMock documentation pages define the features we use. None of the three pages carries a date; we read them on 22 September 2026:
- Simulating Faults: with
fixedDelayMilliseconds, "The response will not be returned until after the specified number of milliseconds." - Stateful Behaviour: a scenario's "starting state is always
Scenario.STARTED", written as"Started"in JSON, andPOST /__admin/scenarios/resetresets all scenarios. - Webhooks and Callbacks: "From version 3.1.0 the webhooks extension is part of WireMock's core and enabled by default."
Why a processor stub needs delays, states, and callbacks
The stubs below give a test four outcomes on demand: an approval held past the client timeout, a replayed approval, a repeated capture that is rejected, and a capture webhook sent after a fixed delay. Those are the paths where money moves twice or not at all: a retry after a lost approval can create a second authorisation, and a capture that the ledger never records leaves a settled payment with no order. The payment flow testing service covers the full list of payment states; this post supplies the stub that drives the failure paths.
The processor here is synthetic. Its API is POST /v1/authorizations and POST /v1/authorizations/{id}/capture, and the stubs select a behaviour by card token (tok_approve, tok_soft_decline, tok_timeout, tok_capture). No card number appears in a mapping, a request, or the request journal, so the stub adds no cardholder data to a test environment.
Stub the payment processor, step by step
Put each file below in mappings/ under a root directory and start two instances: one plays the processor, and one plays the webhook receiver of the system under test.
java -jar wiremock-standalone-3.13.2.jar --port 8089 --root-dir processor
java -jar wiremock-standalone-3.13.2.jar --port 8081 --root-dir receiverThe startup banner confirms the version and the extensions that load by default:
version: 3.13.2
port: 8089
extensions: response-template,webhook1. Approval
processor/mappings/01-approve.json echoes the amount and currency from the request. The body field is a string on purpose: when we put the same template inside jsonBody, WireMock 3.13.2 returned "amount":"1999", a string, and a strict JSON client rejects that for a numeric amount.
{
"request": {
"method": "POST",
"urlPath": "/v1/authorizations",
"headers": { "Idempotency-Key": { "matches": ".+" } },
"bodyPatterns": [
{ "matchesJsonPath": { "expression": "$.card_token", "equalTo": "tok_approve" } }
]
},
"response": {
"status": 201,
"headers": { "Content-Type": "application/json" },
"body": "{ \"id\": \"auth_1001\", \"status\": \"approved\", \"amount\": {{jsonPath request.body '$.amount'}}, \"currency\": \"{{jsonPath request.body '$.currency'}}\" }",
"transformers": ["response-template"]
}
}The Idempotency-Key matcher makes a request without the header fall through to WireMock's 404, so a client that forgets the key fails in the test run.
2. Soft decline
02-soft-decline.json returns a decline that the synthetic processor marks as retryable. Assert that the system under test records the decline, creates no capture, and follows its own documented retry rule.
{
"request": {
"method": "POST",
"urlPath": "/v1/authorizations",
"headers": { "Idempotency-Key": { "matches": ".+" } },
"bodyPatterns": [
{ "matchesJsonPath": { "expression": "$.card_token", "equalTo": "tok_soft_decline" } }
]
},
"response": {
"status": 402,
"headers": { "Content-Type": "application/json" },
"jsonBody": {
"id": "auth_1002",
"status": "declined",
"decline_type": "soft",
"reason": "insufficient_funds",
"retry_allowed": true
}
}
}3. Timeout after approval
03-timeout-after-approval.json approves, holds the response for 35,000 milliseconds, and moves the scenario to Approved. A retry in that state gets the same authorisation back at once, the way an idempotent processor replays a stored result. Set the delay above the client timeout of the system under test; the value here assumes a 30-second client timeout.
{
"mappings": [
{
"scenarioName": "timeout-after-approval",
"requiredScenarioState": "Started",
"newScenarioState": "Approved",
"request": {
"method": "POST",
"urlPath": "/v1/authorizations",
"headers": { "Idempotency-Key": { "matches": ".+" } },
"bodyPatterns": [
{ "matchesJsonPath": { "expression": "$.card_token", "equalTo": "tok_timeout" } }
]
},
"response": {
"status": 201,
"headers": { "Content-Type": "application/json" },
"jsonBody": { "id": "auth_2001", "status": "approved" },
"fixedDelayMilliseconds": 35000
}
},
{
"scenarioName": "timeout-after-approval",
"requiredScenarioState": "Approved",
"request": {
"method": "POST",
"urlPath": "/v1/authorizations",
"headers": { "Idempotency-Key": { "matches": ".+" } },
"bodyPatterns": [
{ "matchesJsonPath": { "expression": "$.card_token", "equalTo": "tok_timeout" } }
]
},
"response": {
"status": 201,
"headers": { "Content-Type": "application/json", "Idempotent-Replayed": "true" },
"jsonBody": { "id": "auth_2001", "status": "approved" }
}
}
]
}In our run on 3.13.2, a client that gave up after 5 seconds got no response, and GET /__admin/scenarios already showed "state" : "Approved". The scenario moves when the request matches, before the delay ends. The stub therefore reproduces a lost approval: the processor has approved, and the caller does not know. The assertions for the retry belong to step 5 of the payment idempotency testing guide, timeout after processor approval.
4. Authorise then capture
04-authorise-capture.json is a three-state machine: Started, Authorised, Captured. A capture before authorisation returns 404, the first capture returns 200, and a second capture returns 409.
{
"mappings": [
{
"scenarioName": "authorise-capture",
"requiredScenarioState": "Started",
"newScenarioState": "Authorised",
"request": {
"method": "POST",
"urlPath": "/v1/authorizations",
"bodyPatterns": [
{ "matchesJsonPath": { "expression": "$.card_token", "equalTo": "tok_capture" } }
]
},
"response": {
"status": 201,
"headers": { "Content-Type": "application/json" },
"jsonBody": { "id": "auth_3001", "status": "approved" }
}
},
{
"scenarioName": "authorise-capture",
"requiredScenarioState": "Started",
"request": { "method": "POST", "urlPath": "/v1/authorizations/auth_3001/capture" },
"response": {
"status": 404,
"headers": { "Content-Type": "application/json" },
"jsonBody": { "error": "authorization_not_found" }
}
},
{
"scenarioName": "authorise-capture",
"requiredScenarioState": "Authorised",
"newScenarioState": "Captured",
"request": { "method": "POST", "urlPath": "/v1/authorizations/auth_3001/capture" },
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"jsonBody": { "id": "cap_3001", "authorization": "auth_3001", "status": "captured" }
},
"serveEventListeners": [
{
"name": "webhook",
"parameters": {
"method": "POST",
"url": "http://localhost:8081/webhooks/processor",
"headers": { "Content-Type": "application/json" },
"delay": { "type": "fixed", "milliseconds": 2000 },
"body": "{ \"event_id\": \"evt_3001\", \"type\": \"payment.captured\", \"authorization\": \"auth_3001\", \"amount\": {{jsonPath originalRequest.body '$.amount'}}, \"reference\": \"{{jsonPath originalRequest.body '$.reference'}}\" }"
}
}
]
},
{
"scenarioName": "authorise-capture",
"requiredScenarioState": "Captured",
"request": { "method": "POST", "urlPath": "/v1/authorizations/auth_3001/capture" },
"response": {
"status": 409,
"headers": { "Content-Type": "application/json" },
"jsonBody": { "error": "already_captured", "capture": "cap_3001" }
}
}
]
}The run below is the order we assert, with the responses WireMock 3.13.2 returned:
POST /v1/authorizations/auth_3001/capture -> 404 {"error":"authorization_not_found"}
POST /v1/authorizations (tok_capture) -> 201 {"id":"auth_3001","status":"approved"}
POST /v1/authorizations/auth_3001/capture -> 200 {"id":"cap_3001","authorization":"auth_3001","status":"captured"}
POST /v1/authorizations/auth_3001/capture -> 409 {"error":"already_captured","capture":"cap_3001"}5. Webhook callback
The capture mapping carries a serveEventListeners webhook. It posts payment.captured to the receiver 2,000 milliseconds after the capture, with the amount and order reference copied from the capture request. The receiver instance on port 8081 had no request right after the capture, and one after 3 seconds:
{
"url": "/webhooks/processor",
"method": "POST",
"body": "{ \"event_id\": \"evt_3001\", \"type\": \"payment.captured\", \"authorization\": \"auth_3001\", \"amount\": 1999, \"reference\": \"ord_1004\" }"
}The 2-second gap lets a test check the window where the API says captured and the ledger has no webhook yet. Point url at the real receiver of the system under test to run the signature, duplicate, and ordering checks in the webhook reliability testing guide.
Checks we run against the stub journal
These are our own conventions for processor stubs on acquiring and billing integrations.
- Reset state before each test. Scenarios are global to one WireMock instance, so two parallel tests on
tok_capturecorrupt each other.
curl -X POST http://localhost:8089/__admin/scenarios/reset
curl -X DELETE http://localhost:8089/__admin/requests- Count calls to the processor, and compare the count with the ledger. After the timeout test, two requests with
tok_timeoutare expected, both with the sameIdempotency-Key:
curl -X POST http://localhost:8089/__admin/requests/count \
-H "Content-Type: application/json" \
-d '{"method":"POST","url":"/v1/authorizations","bodyPatterns":[{"matchesJsonPath":{"expression":"$.card_token","equalTo":"tok_timeout"}}]}'{
"count" : 2,
"requestJournalDisabled" : false
}- Read
GET /__admin/requests/unmatchedat the end of every run. An unmatched request is a call the stub set did not expect, such as a capture sent without an authorisation ID or an authorisation without anIdempotency-Key.
Sources
- WireMock 3.13.2 release, GitHub (published 14 November 2025),
- WireMock 4.0.0-beta.38 pre-release, GitHub (published 2 July 2026),
- WireMock documentation, Simulating Faults (undated page, read 2026-09-22),
- WireMock documentation, Stateful Behaviour (undated page, read 2026-09-22),
- WireMock documentation, Webhooks and Callbacks (undated page, read 2026-09-22),
Related
Guides
Segments
Scope your testing
Send FintechAssuranceLabs your product, segment, money flows, and the standards that need evidence, and get a proposed test scope with its deliverables.
Scope your testing