A REST API for digital wallet top-up via PayId webhook integration. Receives payment notifications, maintains per-user wallet balances, and exposes balance and transaction history endpoints. Built with Express, TypeScript, and SQLite.
- Receives PayId payment webhooks and credits user wallets atomically
- HMAC request authentication with constant-time comparison (timing-attack safe)
- Idempotent payment processing — duplicate transaction IDs are detected and ignored
- Balance stored as integer cents to avoid floating-point rounding errors
- Transaction history ordered by most recent first
- In-process SQLite database via
better-sqlite3— zero external dependencies at runtime
| Layer | Library |
|---|---|
| Runtime | Node.js 24, TypeScript |
| Framework | Express 5 |
| Database | SQLite via better-sqlite3 |
| Query builder | sumak |
| Testing | Jest 30 + ts-jest (ESM mode) + Supertest |
src/
├── server.ts # Entry point — starts HTTP server
├── app.ts # Express app factory
├── types/
│ └── index.ts # Shared TypeScript interfaces
├── db/
│ ├── client.ts # SQLite connection + sumak query builder
│ └── migrate.ts # Creates tables on startup
├── middleware/
│ ├── hmacAuth.ts # HMAC-SHA256 authentication
│ ├── rawBody.ts # Captures raw body for HMAC verification
│ └── validate.ts # Webhook payload schema validation
├── repositories/
│ ├── walletRepository.ts # Wallet DB operations
│ └── transactionRepository.ts # Transaction DB operations
├── services/
│ └── walletService.ts # Business logic, atomic payment processing
├── controllers/
│ ├── walletController.ts # GET /wallets handlers
│ └── webhookController.ts # POST /webhook/payment handler
└── routes/
├── wallet.ts
└── webhook.ts
tests/
├── hmac.test.ts # Unit tests — HMAC computation
├── wallet.test.ts # Integration tests — wallet & transactions
├── webhook.test.ts # Integration tests — payment webhook
└── helpers/
├── db.ts # In-memory DB reset between tests
└── hmac.ts # HMAC signing helper for test requests
- Node.js 18+
- pnpm
pnpm install| Variable | Default | Description |
|---|---|---|
PORT |
XXXX |
HTTP port |
HMAC_KEY |
(dev key) | Key for HMAC verification |
DB_PATH |
./wallet.db |
Path to the SQLite database file |
Create a .env file or export variables directly:
export HMAC_KEY=
export PORT=npx ts-node src/server.tspnpm run build
node dist/server.jsReturns service health status.
Response
{ "status": "ok" }Receives a PayId payment notification and credits the user's wallet.
Authentication
Requires an Authorization header with an HMAC signature of the raw request body:
Authorization: HMAC_SHA256 <hex_signature>
Compute the signature:
HMAC_KEY="your_64_char_hex_key"
BODY='{"transactions":[...]}'
SIG=$(echo -n "$BODY" | openssl dgst -sha256 -mac HMAC -macopt hexkey:$HMAC_KEY | awk '{print $2}')Request body
{
"transactions": [
{
"id": "tx-001",
"created_at": "2024-01-10T10:00:00.000Z",
"updated_at": "2024-01-10T10:00:00.001Z",
"description": "Credit of $50.00",
"type": "deposit",
"type_method": "npp_payin",
"state": "successful",
"user_id": "user-123",
"user_name": "Jane Smith",
"amount": "50.00",
"currency": "AUD",
"debit_credit": "credit"
}
]
}Required fields per transaction: id, user_id, user_name, amount (positive number string), currency
Response 200 OK
{
"success": true,
"data": [
{
"transaction_id": "tx-001",
"user_id": "user-123",
"is_duplicate": false,
"wallet_balance_cents": 5000
}
]
}is_duplicate: true is returned when a transaction ID has already been processed. The wallet is not credited again.
Error responses
| Status | Condition |
|---|---|
400 |
Missing or invalid transactions array, or a required field is absent/invalid |
401 |
Missing Authorization header, wrong scheme, or HMAC mismatch |
Returns the wallet for a user.
Response 200 OK
{
"success": true,
"data": {
"id": "a1b2c3d4-...",
"user_id": "user-123",
"balance_cents": 5000,
"created_at": "2024-01-10T10:00:00.000Z",
"updated_at": "2024-01-10T10:00:00.001Z"
}
}Response 404 Not Found
{
"success": false,
"error": "Wallet not found for this user"
}Wallets are created automatically on the first payment received for a user.
Returns all transactions for a user, ordered by created_at descending (most recent first). Returns an empty array if the user has no transactions.
Response 200 OK
{
"success": true,
"data": [
{
"id": "tx-001",
"user_id": "user-123",
"amount_cents": 5000,
"currency": "AUD",
"type": "deposit",
"type_method": "npp_payin",
"state": "successful",
"description": "Credit of $50.00",
"debit_credit": "credit",
"user_name": "Jane Smith",
"created_at": "2024-01-10T10:00:00.000Z",
"updated_at": "2024-01-10T10:00:00.001Z"
}
]
}vp run test
# or
npm testAll tests use an in-memory SQLite database (set via DB_PATH=:memory:) that is reset before each test. No external services required.
Test coverage (25 tests across 3 suites)
| Suite | Tests | Coverage |
|---|---|---|
hmac.test.ts |
5 | HMAC computation, timing-safe equality |
wallet.test.ts |
8 | Wallet balance, transaction listing, ordering, isolation |
webhook.test.ts |
12 | Auth, validation, processing, idempotency, atomicity |
See TEST_RESULTS.md for the full test run log.
| Column | Type | Notes |
|---|---|---|
id |
TEXT | UUID, primary key |
user_id |
TEXT | Unique per user |
balance_cents |
INTEGER | Running total in cents |
created_at |
TEXT | ISO 8601 |
updated_at |
TEXT | ISO 8601, updated on each credit |
| Column | Type | Notes |
|---|---|---|
id |
TEXT | Primary key — used for idempotency |
user_id |
TEXT | |
amount_cents |
INTEGER | Stored in cents |
currency |
TEXT | e.g. AUD |
type |
TEXT | e.g. deposit |
type_method |
TEXT | e.g. npp_payin |
state |
TEXT | e.g. successful |
description |
TEXT | |
debit_credit |
TEXT | e.g. credit |
user_name |
TEXT | |
created_at |
TEXT | ISO 8601 |
updated_at |
TEXT | ISO 8601 |
Atomic payment processing Wallet creation, balance credit, and transaction insert are wrapped in a single SQLite transaction. Concurrent duplicate requests cannot partially apply a payment.
Idempotency via INSERT OR IGNORE
Transactions are inserted with INSERT OR IGNORE. If a transaction ID already exists, the insert is a no-op and the existing wallet balance is returned with is_duplicate: true.
HMAC timing safety
Signature comparison uses crypto.timingSafeEqual to prevent timing oracle attacks. The raw request body is captured before Express parses the JSON so the exact bytes the sender signed are compared.
Balance as integer cents
amount strings from the webhook (e.g. "18.99") are converted to cents via Math.round(parseFloat(amount) * 100) to avoid IEEE 754 floating-point accumulation errors.
See CHANGELOG.md for a full history of changes and architectural decisions.