Skip to content

How to test webhook delivery from BaaS and payment providers

Test a Banking-as-a-Service or payment provider webhook receiver in four steps: verify the HMAC (hash-based message authentication code) signature on the raw body, reject stale timestamps, force retries and duplicates, and reconcile processed events against the provider's event list. The check that catches late double postings is a deduplication record kept longer than the provider's longest redelivery path.

Where webhook receivers lose money

A card clearing, an incoming ACH credit, or a returned transfer reaches your platform as a webhook, and your handler turns it into a ledger posting. Four defects in that handler reach production because a sandbox run only exercises the happy path:

  • Double posting on retry. The provider times out waiting for your response, retries, and your handler posts the same transaction again. The end user's balance moves twice while the partner bank core shows one movement.
  • Missed posting. Every retry fails during an outage, the provider stops, and the event never reaches the ledger. The account shows a stale balance until someone reconciles.
  • Stale state wins. A pending status arrives after posted and your handler overwrites the newer state.
  • Forged or replayed event. A receiver that skips signature or timestamp checks accepts a captured request sent again later.

For a consumer account, a computational or bookkeeping error made by the financial institution relating to an electronic fund transfer is an error under Regulation E, 12 CFR 1005.11(a)(1)(iv). Section 1005.11(c)(1) then requires the institution to determine whether an error occurred within 10 business days of the notice, subject to the exceptions in that paragraph. The Regulation E page covers the rest of the rule; the BaaS segment page covers where these postings sit in a programme ledger.

Procedure

The examples use two providers whose documentation states their webhook behaviour: Increase and Lithic (both read on 2026-09-22). Both use the header names defined in the Standard Webhooks specification: webhook-id, webhook-timestamp, and webhook-signature. Sender-side checks of the same scheme belong to API and webhook testing; this guide tests the receiver.

1. Capture a real delivery shape

Increase sends a thin event: identifiers only, with the full object fetched through the API. A captured sandbox delivery looks like this (test values):

Request
POST /webhooks/increase HTTP/1.1
Host: receiver.example.com
Content-Type: application/json
webhook-id: event_test_001
webhook-timestamp: 1790000000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{"id":"event_test_001","created_at":"2026-09-21T14:13:20Z","category":"transaction.created","associated_object_type":"transaction","associated_object_id":"transaction_test_abc","type":"event"}

The signature value above is a placeholder. Increase counts a delivery as successful only when your endpoint returns a 2xx within 10 seconds.

2. Verify the signature on the raw body

The signed content is webhook-id, a period, webhook-timestamp, a period, and the raw body, signed with HMAC-SHA256 and Base64-encoded with a v1, prefix. Lithic documents a secret with a whsec_ prefix whose Base64 remainder is the key. The header can carry several space-separated signatures during secret rotation.

Python
import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300  # receiver policy; both vendors' examples use 5 minutes


def verify(secret: str, headers: dict, raw_body: bytes, now: int | None = None) -> bool:
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    now = int(time.time()) if now is None else now
    if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    for candidate in headers["webhook-signature"].split(" "):
        version, _, value = candidate.partition(",")
        if version == "v1" and hmac.compare_digest(value, expected):
            return True
    return False

The tests that matter feed the handler bytes, not parsed JSON:

Python
import base64
import hashlib
import hmac
import json

from receiver import verify

SECRET = "whsec_" + base64.b64encode(b"test-only-secret").decode()
BODY = b'{"id":"event_test_001","category":"transaction.created"}'
TS = 1790000000


def sign(body: bytes, ts: int = TS, msg_id: str = "event_test_001") -> dict:
    key = base64.b64decode(SECRET.removeprefix("whsec_"))
    mac = hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
    return {"webhook-id": msg_id, "webhook-timestamp": str(ts),
            "webhook-signature": "v1," + base64.b64encode(mac).decode()}


def test_reserialised_body_fails():
    reserialised = json.dumps(json.loads(BODY)).encode()  # adds spaces after separators
    assert not verify(SECRET, sign(BODY), reserialised, now=TS)


def test_stale_timestamp_fails():
    assert not verify(SECRET, sign(BODY), BODY, now=TS + 301)


def test_rotation_second_signature_passes():
    headers = sign(BODY)
    headers["webhook-signature"] = "v1,AAAA " + headers["webhook-signature"]
    assert verify(SECRET, headers, BODY, now=TS)

A handler that receives a parsed object from its framework fails the first test. The Standard Webhooks specification warns that even a stray space invalidates the signature and tells implementers to use a constant-time comparison for symmetric signatures.

3. Force retries and duplicates

Make the receiver fail on purpose: return 500, return 2xx after 11 seconds, and drop the connection after the ledger commit. Then assert one posting per webhook-id. Increase states that webhook-id is the Event ID and asks receivers to use it as the idempotency key; Lithic states that it stays unchanged when a message is resent.

SQL
CREATE TABLE webhook_inbox (
  webhook_id   text PRIMARY KEY,
  received_at  timestamptz NOT NULL DEFAULT now()
);

-- Same transaction as the ledger posting. No row returned means a duplicate: skip and return 200.
INSERT INTO webhook_inbox (webhook_id)
VALUES ($1)
ON CONFLICT (webhook_id) DO NOTHING
RETURNING webhook_id;

The inbox insert and the posting must commit together. A handler that writes the inbox row after posting double-posts when it crashes between the two writes. Request-side deduplication for your own outgoing calls is a separate problem; the payment idempotency testing guide covers it.

4. Deliver events out of order

Send transaction.created after a later update for the same object. With thin events, the handler fetches the current object, so the test checks that the handler re-fetches instead of reading a cached copy. With full payloads, the handler must compare a version or timestamp on the object and discard the older one.

5. Reconcile against polling

Webhooks are a notification path; the event list is the record. Increase returns events from its List Events API for 30 days after creation, oldest first when you ask for it, with a cursor:

Request
GET /events?order_by.field=created_at&order_by.direction=ascending&cursor=CURSOR_FROM_PREVIOUS_PAGE HTTP/1.1
Host: api.increase.com
Authorization: Bearer TEST_KEY_NOT_REAL

Load the pages into provider_events and find what the receiver never processed:

SQL
SELECT p.event_id, p.category, p.created_at
FROM provider_events AS p
LEFT JOIN webhook_inbox AS w ON w.webhook_id = p.event_id
WHERE w.webhook_id IS NULL
  AND p.created_at < now() - interval '15 minutes'
ORDER BY p.created_at;

Run the job daily over the full 30-day window, so an outage longer than the retry schedule is recovered before the events expire from the list. The ledger reconciliation guide continues from the event level to balances.

6. Test locally through a tunnel

Expose the local receiver, register the tunnel URL as a sandbox endpoint, and replay captured deliveries through the ngrok agent API:

Shell
ngrok http 8080
curl -s "http://127.0.0.1:4040/api/requests/http?limit=20"
curl -s -X POST http://127.0.0.1:4040/api/requests/http \
  -H "Content-Type: application/json" \
  -d '{"id":"CAPTURED_REQUEST_ID"}'

Increase does not retry failed webhooks in the sandbox, so sandbox runs never exercise your retry path; replays and a local signer built from the sign() fixture do. Point tunnels at sandbox only: a tunnel that forwards production events can carry account data to a developer laptop.

The retention window vendor docs leave to you

Neither vendor page cited here states how long your deduplication record must live. The answer is the longest path by which the same webhook-id can arrive again:

ProviderRedelivery pathLatest repeat after the event
Increase8 automatic attempts, the last about 72 hours after the eventabout 72 hours
Lithic8 attempts: immediate, then +5 s, +5 min, +30 min, +2 h, +5 h, +10 h, +10 habout 27 hours 35 minutes
LithicManual resend of a single messageany time
LithicReplay Missing when you register a replacement endpointup to 90 days

A deduplication key in a cache with a 24-hour expiry passes every sandbox test and still double-posts the Increase attempt at about 72 hours and the last automatic Lithic attempt. Keep inbox rows at least as long as the longest path, 90 days in this table, and add a unique constraint on the provider object ID in the ledger as a second guard.

The replay window has the opposite pitfall. The Standard Webhooks specification says the attempt timestamp is updated on every retry, so a genuine retry always passes a 5-minute tolerance. An ngrok replay reuses the old timestamp and must fail. Teams that widen the tolerance to make local replays pass ship a receiver that accepts a captured request days later. Keep the tolerance and replay fresh signatures from the fixture instead.

The 10-second success limit at Increase is shorter than the 15 to 30 second timeout range the Standard Webhooks specification suggests to senders. A handler that posts synchronously and waits on a slow partner bank call can pass 10 seconds under load, and each retry it triggers repeats work already committed.

Test matrix

CaseInputExpected result
Valid signatureRaw body, current timestamp2xx, one posting
Re-serialised bodyParsed and re-encoded JSONRejected
Stale timestampTimestamp 301 seconds oldRejected
Secret rotationTwo signatures, second validAccepted
Retry after 500Same webhook-id twiceOne posting
Late response2xx after 11 secondsOne posting after retry
Crash after commitConnection dropped after postingOne posting after retry
Late duplicateSame webhook-id 72 hours laterOne posting
Out of orderOlder state after newer stateNewer state kept
Missed eventEndpoint down past the last attemptFound by polling reconciliation

Partner banks reviewing a programme ask for this evidence alongside reconciliation reports; BaaS and partner bank testing packages it for that review.

Questions

How to test webhooks locally?

Test webhooks locally by exposing the local receiver through a tunnel such as ngrok, pointing a provider sandbox endpoint at the tunnel URL, and replaying captured deliveries from the ngrok agent application programming interface (API). A replay older than the receiver's timestamp tolerance, commonly 5 minutes, must be rejected, so a failed replay is a passing signature test.

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