Skip to main content

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

ConstraintValueError on violation
available >= amountRequired400 Insufficient Funds
Hold on already-frozen fundsForbidden400 Bad Request
Max concurrent holds per wallet100 (MONEY_MAX_HOLDS_PER_WALLET=100)429 Too Many Requests
Default TTL72 hours (MONEY_HOLD_TTL_HOURS=72)
Maximum TTL168 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

ParameterValueEnv variable
Ticker interval60 secondsMONEY_HOLD_CLEANUP_INTERVAL_SEC=60
SQL operationUPDATE transactions SET status = 'canceled' WHERE status = 'held' AND created_at + ttl < NOW() RETURNING *
Events publishedmoney.hold.expired (RabbitMQ)
Audit recordWritten 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

ScenarioHTTPCode
Insufficient available balance400INSUFFICIENT_FUNDS
Max 100 concurrent holds reached429HOLD_LIMIT_EXCEEDED
TTL exceeds 168 h (7 days)400validation-error
Confirm on non-held transaction400INVALID_HOLD_STATUS
Cancel on non-held transaction400INVALID_HOLD_STATUS
Confirm already canceled hold409HOLD_ALREADY_CANCELED
holdTransactionId not found404not-found