SeptemCore LogoSeptemCore
PrimitivesMoney

Hold / Confirm / Cancel

Two-phase debit pattern: hold freezes funds (available -= amount, frozen += amount). Confirm finalizes as debit (frozen -= amount). Cancel returns to available. TTL default 72h, max 7 days. Auto-cancel background worker every 60s. Max 100 concurrent holds per wallet.

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

On this page