A production-style service for secure and idempotent payment webhook processing.
The project demonstrates how to protect payment integrations against invalid signatures, replay attacks, duplicate delivery and concurrent processing.
- HMAC-SHA256 webhook signature verification
- Timestamp validation and stale request rejection
- Replay attack protection using unique nonces
- Idempotent processing of duplicate webhook deliveries
- Controlled payment state transitions
- Concurrency-safe account crediting
- Exactly-once business effect for successful payments
- Unit and end-to-end test coverage
- Isolated application and infrastructure layers
flowchart LR
MerchantClient[Merchant client] --> InvoiceAPI[Invoice API]
Provider[Payment provider] --> WebhookAPI[Webhook API]
InvoiceAPI --> InvoiceService[Invoice service]
WebhookAPI --> Verification[HMAC and timestamp verification]
Verification --> ReplayProtection[Nonce replay protection]
ReplayProtection --> WebhookService[Webhook service]
InvoiceService --> MerchantRepository[(Merchant repository)]
InvoiceService --> InvoiceRepository[(Invoice repository)]
WebhookService --> MerchantRepository
WebhookService --> InvoiceRepository
WebhookService --> CreditService[Credit service]
ReplayProtection --> Redis[(Redis)]
MerchantRepository --> MongoDB[(MongoDB)]
InvoiceRepository --> MongoDB
- TypeScript
- Node.js
- Express
- MongoDB and Mongoose
- Redis and ioredis
- Jest
- Supertest
- MongoDB Memory Server
- Prettier
src/
├── application/ # Use cases and application ports
├── domain/ # Invoice rules, money logic, and domain errors
├── infrastructure/ # MongoDB, Redis, and external-service adapters
├── libs/ # Cryptography, HTTP, and logging utilities
├── presentation/ # Express application, routers, and error handling
├── config.ts # Environment configuration
└── server.ts # Composition root and application entry point
tests/
├── helpers.ts
└── webhook.e2e.test.ts
Run all tests:
npm testRun unit tests:
npm run test:unitRun end-to-end tests:
npm run test:e2eThe test suite covers:
- valid signatures
- modified payloads
- incorrect secrets
- malformed and missing signatures
- stale timestamps
- missing headers
- reused nonces
- duplicate deliveries
- terminal-state protection
- unknown invoices
- invalid statuses
- simultaneous webhook processing
- recovery after partial processing failure
import crypto from "node:crypto";
const secret = "demo-webhook-secret";
const body = JSON.stringify({
invoiceId: "replace-with-an-invoice-id",
status: "paid"
});
const signature = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
const response = await fetch("http://localhost:3000/webhook", {
method: "POST",
headers: {
"content-type": "application/json",
"x-signature": signature,
"x-timestamp": String(Math.floor(Date.now() / 1000)),
"x-nonce": crypto.randomUUID()
},
body
});
console.log(response.status, await response.json());