Webhook Test Engine checks whether an application handles webhook events correctly, not just whether its HTTP endpoint returns a successful status code.
A webhook handler can return 200 OK and still be wrong. It may accept an
invalid signature, fail to persist the expected state, or store the wrong
payment details. This project delivers a realistic webhook and then inspects
the application's persistent state to verify the business outcome.
The repository currently implements one end-to-end Paystack charge.success
scenario:
- The engine builds a synthetic payment event.
- It signs the exact JSON request body with HMAC-SHA512.
- It sends the same HTTP POST once, or the requested number of times
sequentially, with the
X-Paystack-Signatureheader. - The example application verifies the signature and writes the payment to SQLite.
- The engine opens the database read-only and polls for the expected payment.
- The test passes only when every HTTP response is 2xx and the stored reference, amount, currency, and status all match.
- With repeated delivery, exactly one payment record must exist for the reference.
An HTTP 200 response without the expected database state is reported as a failure. This is the defining behaviour of the project. A non-2xx response also fails the scenario, even when the stored payment happens to match.
This is the only implemented engine scenario. Repeated delivery reuses the same signed request; different events for the same payment, retries, other providers, and scenario files are not implemented.
- Go 1.23.4 or newer.
- No CGO toolchain: SQLite is provided by
modernc.org/sqlite.
cmd/
example-app/ SQLite-backed receiver used as the application under test
webhook-test/ test runner and CLI
internal/
engine/ delivery, polling, and PASS/FAIL evaluation
exampleapp/ webhook handler and SQLite persistence
paystack/ Paystack event payload and HMAC-SHA512 signatures
provider/ small adapter boundary between engine and provider
Start the example application in one terminal:
go run ./cmd/example-appThe example app defaults to:
--addr 127.0.0.1:8080
--db example.db
--secret dev-secret
dev-secret is a clearly labelled development-only secret. Do not use a real
Paystack key with this example.
In a second terminal, run the test:
go run ./cmd/webhook-test `
--target http://127.0.0.1:8080/webhooks/paystack `
--db .\example.db `
--secret dev-secretA successful run prints:
PASS paystack reference=whe_... http=200 state=verified
To deliver the same signed webhook twice and check for duplicate records, add
--deliveries:
go run ./cmd/webhook-test `
--target http://127.0.0.1:8080/webhooks/paystack `
--db .\example.db `
--secret dev-secret `
--deliveries 2A successful duplicate-delivery run prints one status per delivery:
PASS paystack reference=whe_... http=200,200 state=verified
A failure prints a FAIL line with the HTTP statuses and the reason, for example:
FAIL paystack reference=whe_... http=200: expected payment "whe_..." not found before timeout
The engine exits with:
0when every delivery was accepted and the expected state was verified.1when delivery, HTTP, database, timeout, or state verification failed.2for invalid command-line usage.
These are the exit codes of the compiled webhook-test binary. go run
reports its own status and can collapse a 2 into 1, so build the binary
when a script depends on the exact code.
| Flag | Default | Meaning |
|---|---|---|
--target |
required | Absolute HTTP or HTTPS webhook endpoint |
--db |
required | Path to the application SQLite database |
--secret |
required | Webhook signing secret; use a development-only value |
--reference |
generated | Payment reference to send and verify |
--amount |
50000 |
Amount in minor currency units |
--currency |
NGN |
Payment currency |
--deliveries |
1 |
Number of times to deliver the same signed webhook sequentially |
--timeout |
2s |
Total timeout for preflight, all deliveries, and state verification |
Amounts use integer minor currency units. For NGN, 50000 means 500.00 NGN
(kobo). The engine does not convert or round amounts.
--timeout is one budget for the whole scenario, not a per-delivery timeout.
Raising --deliveries therefore leaves less time for each delivery and for the
final state check.
Version 1 requires SQLite. The engine opens the database read-only and expects
a table named payments with at least these columns:
CREATE TABLE payments (
reference TEXT PRIMARY KEY,
amount INTEGER NOT NULL,
currency TEXT NOT NULL,
status TEXT NOT NULL
);reference must uniquely identify the payment, amount must be stored in
integer minor currency units, currency must be the currency code sent in the
event, and status must be the value expected by the test (success for
charge.success). The example application also stores a received_at column;
the engine does not query it.
The database file must already exist and contain this table before the test
runs. The engine does not create or migrate the schema. Arbitrary application
schemas, configurable table or column names, and other database engines are not
supported yet. To test a different application in v1, provide a compatible
SQLite database and payments table.
- The Paystack adapter builds a minimal
charge.successevent and signs the exact serialized body with HMAC-SHA512. The request is built once. - The engine checks that the reference does not already exist in SQLite, so an old row cannot produce a false pass.
- The engine sends the webhook, or
--deliveriescopies of it, sequentially. Every delivery reuses the same body and signature. - A transport error or non-2xx response fails immediately. With multiple deliveries, the failure names the delivery number.
- After the first delivery of a multi-delivery run, and after the final delivery, the engine polls SQLite every 50 ms until the configured timeout.
- The expected reference, amount, currency, and status must all match.
- Exactly one record must exist for that reference.
The example application uses the payment reference as its idempotency key.
When a valid charge.success event is repeated with the same reference, amount,
currency, and status, the handler returns HTTP 200 without inserting another
row. If the reference already exists with a different amount or currency, the
handler returns HTTP 409 and leaves the original row unchanged. Genuine
database errors still return HTTP 500.
The engine can send the same signed webhook more than once with --deliveries.
The request is built once, so every delivery carries the exact same body and
signature rather than a newly generated event. Deliveries are sequential and
the run stops at the first transport error or non-2xx response. The engine
verifies the expected payment and exactly one matching record after the first
delivery and again after the final delivery. This catches an application that
ignores the first event and persists only on a later duplicate.
For a single delivery, the state check runs once as before. The record count is a point-in-time check after delivery; it cannot prove that no asynchronous write happens later. The engine also refuses to send a reference that already exists in the database.
This feature tests repeated delivery of one webhook. It does not test different events relating to the same payment, concurrent duplicate delivery, retries, backoff, or general event-ID-based deduplication.
Database errors are reported distinctly from a missing payment row. A failed query is never treated as evidence that no payment exists.
Run the automated tests with:
go test ./...The suite runs without network access or real provider credentials: every test
uses a temporary SQLite database and a local httptest server.
Coverage includes:
- HMAC-SHA512 signatures checked against a fixed, independently computed vector, plus changed-body and wrong-secret rejection.
- Correctly signed events persisted, and invalid signatures, malformed JSON, and unsupported events rejected without writing a row.
- Repeated and conflicting deliveries to the example handler: exact duplicates return 200 with one stored row, while a reused reference with a different amount or currency returns 409 and leaves the original unchanged.
- An end-to-end pass where the expected reference, amount, currency, and status are all present in SQLite.
- Two sequential deliveries of the same request: both return 200, the bodies and signatures are identical, and only one payment record is stored.
- One delivery remains the default when
--deliveriesis omitted. - A failure when an application stores a second record for the same reference.
- A failure with the delivery number and recorded statuses when a later delivery returns a non-2xx response.
- A failure when the first delivery returns 200 but never persists the payment, even if a later delivery would.
- Rejection of negative delivery counts in the engine and CLI.
- A failure when a handler returns HTTP 200 without writing any state, and when it persists the wrong amount.
- A failure on a non-2xx response even when the persisted state happens to match.
- Refusal to send when the reference already exists, so an old row cannot produce a false pass.
- Distinct failures for missing databases, missing schema, and delivery timeouts.
Implemented:
- Go CLI and engine orchestration
- Paystack
charge.successevent generation and HMAC-SHA512 signatures - One or more sequential deliveries of the same signed webhook, with non-2xx rejection and per-delivery HTTP status reporting
- Read-only SQLite state verification, including exactly-one-record checks for duplicate deliveries
- SQLite-backed example application
- Idempotent duplicate handling in the example app by payment reference
- PASS/FAIL reporting and CI-friendly exit codes
- Automated tests, including the HTTP-200-without-state and non-2xx regressions
Planned, but not implemented:
- Different events for the same payment and retry/backoff scenarios
- Concurrent duplicate delivery
- Additional Paystack event types
- Other payment providers and webhook systems
- A scenario configuration format, which is still undecided
- Additional state probes and example applications
The broader goal is a general webhook testing engine built around small
provider adapters, not a Paystack-only tool. The core engine currently depends
on a provider adapter interface; all Paystack payload and signature details live
in internal/paystack.
- Live or test Paystack API calls
- Real money movement
- Load or performance testing
- Docker and CI configuration
- A dashboard or web interface
Not yet chosen.