Hold / Confirm / Cancel
The hold/confirm/cancel pattern is the two-phase debit mechanism for integrating Money Service with external payment gateways (Stripe, PayPal, etc.). A hold freezes funds without debiting them — the debit finalizes only after the external gateway confirms.
When to Use
Do not use hold/confirm/cancel for simple internal operations.
Use debit() directly for immediate, internal withdrawals.
Use hold/confirm/cancel when:
- An external payment gateway may take seconds or minutes to confirm
- Funds must be reserved while awaiting an async callback
- Partial cancellation may occur (cancel + new hold for the remaining amount)
Standard flow with external gateway:
1. Module: kernel.money().hold(walletId, 5000)
→ available: 10000 → 5000
→ frozen: 0 → 5000
→ tx status: held
2. Module: initiate payment with Stripe for $50
3. Stripe callback (async, seconds to minutes):
┌─ PAYMENT OK → kernel.money().confirm(holdTxId)
│ frozen: 5000 → 0
│ tx status: confirmed (= debit)
│
└─ PAYMENT FAIL → kernel.money().cancel(holdTxId)
frozen: 5000 → 0
available: 5000 → 10000
tx status: canceled
Hold
Freeze funds on a wallet. The funds are deducted from available
and moved to frozen. The money remains in the wallet but cannot
be spent until the hold is confirmed or canceled.
const holdTx = await kernel.money().hold({
walletId: '01j9paw1t000000000000000',
amount: 5000, // $50.00 in cents
idempotencyKey: crypto.randomUUID(),
ttl: 259200, // seconds; default 72h = 259200
description: 'Payment hold for Stripe checkout',
metadata: { checkoutId: 'cs_test_01j9pach...' },
});
// holdTx:
// {
// transactionId: '01j9pah1d000000000000000',
// type: 'hold',
// status: 'held',
// amount: 5000,
// walletId: '01j9paw1t000000000000000',
// ttl: 259200,
// expiresAt: '2026-04-18T10:30:00.000Z',
// balanceAfter: {
// available: 5000,
// frozen: 5000
// }
// }
POST https://api.septemcore.com/v1/wallets/01j9paw1t000000000000000/hold
Authorization: Bearer <access_token>
Content-Type: application/json
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
{
"amount": 5000,
"ttl": 259200,
"description": "Payment hold for Stripe checkout",
"metadata": { "checkoutId": "cs_test_01j9pach..." }
}
Response 201 Created:
{
"transactionId": "01j9pah1d000000000000000",
"type": "hold",
"status": "held",
"amount": 5000,
"walletId": "01j9paw1t000000000000000",
"ttl": 259200,
"expiresAt": "2026-04-18T10:30:00.000Z",
"balanceAfter": {
"available": 5000,
"frozen": 5000
}
}
Hold Constraints
| Constraint | Value | Error on violation |
|---|---|---|
available >= amount | Required | 400 Insufficient Funds |
| Hold on already-frozen funds | Forbidden | 400 Bad Request |
| Max concurrent holds per wallet | 100 (MONEY_MAX_HOLDS_PER_WALLET=100) | 429 Too Many Requests |
| Default TTL | 72 hours (MONEY_HOLD_TTL_HOURS=72) | — |
| Maximum TTL | 168 hours (7 days) | 400 validation-error |
Confirm
Finalize a hold as a permanent debit. The frozen amount is consumed
and debited. available is not changed — it was already reduced at
hold time.
const confirmTx = await kernel.money().confirm({
holdTransactionId: '01j9pah1d000000000000000',
idempotencyKey: crypto.randomUUID(),
});
// confirmTx:
// {
// transactionId: '01j9pac1f000000000000000',
// type: 'confirm',
// status: 'completed',
// holdTransactionId:'01j9pah1d000000000000000',
// amount: 5000,
// balanceAfter: {
// available: 5000,
// frozen: 0
// }
// }
POST https://api.septemcore.com/v1/wallets/01j9paw1t000000000000000/confirm
Authorization: Bearer <access_token>
Content-Type: application/json
Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890
{
"holdTransactionId": "01j9pah1d000000000000000"
}
Confirm is supported only on holds in held status. Partial confirm
is not supported — the entire hold amount is debited. For partial
confirm: cancel the hold and create a new hold for the reduced amount.
Cancel
Release a hold and return frozen funds to available.
const cancelTx = await kernel.money().cancel({
holdTransactionId: '01j9pah1d000000000000000',
idempotencyKey: crypto.randomUUID(),
reason: 'Payment gateway declined',
});
// cancelTx.balanceAfter:
// { available: 10000, frozen: 0 }
POST https://api.septemcore.com/v1/wallets/01j9paw1t000000000000000/cancel
Authorization: Bearer <access_token>
Content-Type: application/json
Idempotency-Key: b2c3d4e5-f6a7-8901-bcde-f12345678901
{
"holdTransactionId": "01j9pah1d000000000000000",
"reason": "Payment gateway declined"
}
TTL and Auto-Cancel
Every hold has an expiry time (expiresAt = createdAt + ttl).
When the TTL elapses, a background worker auto-cancels the hold
and returns funds to available.
Auto-Cancel Worker
| Parameter | Value | Env variable |
|---|---|---|
| Ticker interval | 60 seconds | MONEY_HOLD_CLEANUP_INTERVAL_SEC=60 |
| SQL operation | UPDATE transactions SET status = 'canceled' WHERE status = 'held' AND created_at + ttl < NOW() RETURNING * | — |
| Events published | money.hold.expired (RabbitMQ) | — |
| Audit record | Written after each batch auto-cancel | — |
Worker Health Check
The worker writes a heartbeat to Valkey only after a successful cleanup cycle — not at startup:
Worker ticker fires every 60s:
→ Execute UPDATE ... RETURNING *
→ Update wallet balances for each expired hold
→ Publish money.hold.expired per expired hold
→ SET heartbeat:money-hold-worker NOW() EX 300 ← only on success
Crash loop (restart every 30s):
→ Worker never reaches the ticker
→ Heartbeat not updated
→ Alert fires after 5 minutes of missing heartbeat
This design ensures that a crash loop triggers an alert, whereas a healthy worker that simply found no expired holds still updates the heartbeat.
Balance Walkthrough: Hold → TTL Expiry
t=0: hold(5000) available: 10000→5000, frozen: 0→5000 [held]
t=72h: TTL expires available: 5000→10000, frozen: 5000→0 [canceled]
event: money.hold.expired published
audit: hold auto-canceled after TTL
Concurrent Hold and Debit
Simultaneous holds and debits on the same wallet are serialized
via SELECT ... FOR UPDATE (row-level pessimistic lock):
Thread A: hold $50 on wallet-1
Thread B: debit $80 on wallet-1 (simultaneously, available = $100)
PostgreSQL:
Thread A acquires row lock → hold: available 100→50, frozen 0→50
Thread B waits → acquires lock → checks available >= 80
available is now 50, not 100 → 400 Insufficient Funds
The CHECK (balance >= 0) constraint provides a second layer —
negative balance is architecturally impossible even under concurrency.
Error Reference
| Scenario | HTTP | Code |
|---|---|---|
Insufficient available balance | 400 | INSUFFICIENT_FUNDS |
| Max 100 concurrent holds reached | 429 | HOLD_LIMIT_EXCEEDED |
| TTL exceeds 168 h (7 days) | 400 | validation-error |
Confirm on non-held transaction | 400 | INVALID_HOLD_STATUS |
Cancel on non-held transaction | 400 | INVALID_HOLD_STATUS |
| Confirm already canceled hold | 409 | HOLD_ALREADY_CANCELED |
holdTransactionId not found | 404 | not-found |