Zero-trust callback handler for SATIM gateway notifications.
Receive a callback or redirect from SATIM and produce a server-verified ConfirmResponse. The callback payload is never trusted — every invocation triggers a server-to-server satim.confirm() against the real gateway, with automatic amount verification.
This is strictly stronger than HMAC signature verification: a valid signature proves the payload was issued by the gateway, but does not prove the payload reflects current state. Re-fetching live state defeats both replay attacks and stale-webhook races.
| File | Responsibility |
|---|---|
rate-limiter.ts |
SlidingWindowRateLimiter. Binary-search expiry pruning with a head pointer; periodic array compaction. check() is amortised O(log n) per call. |
extract.ts |
extractOrderId. Multi-source extraction from string, URL, Web Request, or plain object with orderId. Returns null for anything that fails the strict [a-zA-Z0-9\-]{1,128} format. |
handler.ts |
WebhookHandler. The public class. Orchestrates extraction, rate limit, in-flight lock, duplicate check, confirm(), and mark-processed. |
External (within src/) |
Used for |
|---|---|
../Satim (type only) |
The handler holds a reference to the Satim instance for server-to-server verification. |
../responses/confirm (type only) |
ConfirmResponse appears in WebhookResult. |
../exceptions |
SatimInvalidArgumentError, SatimMissingDataError for construction-time validation. |
handler.verify(source):
extract.ts. Invalid → return null.null.verify() is already running for this orderId, await it and return { duplicate: true, response: <its response> }. Otherwise acquire the lock for the duration of this call.onCheckDuplicate(orderId). If true, still call onResolveAmount and satim.confirm() so the caller receives the same verified response, but flagged duplicate: true.onResolveAmount(orderId). Unknown order → return null (do not call the gateway for orders the merchant does not recognise).satim.confirm(orderId, expectedAmount). confirm runs verifyAmount() automatically on success.onMarkProcessed(orderId) only if the response is not isPending(). Pending orders are intentionally not marked so subsequent callbacks can re-check as the order progresses to a terminal state.finally.The in-process duplicate set (processedSet) and the in-flight lock (inflightLocks) are per-process. Multi-instance deployments (Kubernetes pods, multiple dynos, serverless cold starts) must:
onCheckDuplicate and onMarkProcessed backed by a shared store.onCheckDuplicate (Redis SETNX, database INSERT ... ON CONFLICT DO NOTHING). The handler calls them separately, but cross-instance correctness requires the atomic step to live inside onCheckDuplicate.The handler emits a console.warn at construction time when neither callback is provided. Set suppressMultiInstanceWarning: true only after confirming single-process deployment.
SlidingWindowRateLimiter(maxRequests, windowMs):
check() returns true if the request is admitted, false if the window is full.head pointer past expired entries on each call.head > 1000, the array is sliced to reclaim memory. Amortised allocation cost is O(1) per call.O(log n) per check() where n is the number of unexpired timestamps.Defaults exposed via WebhookHandlerOptions: maxCallbacksPerWindow = 100, rateLimitWindowMs = 60_000.
| Editing | Potentially affects |
|---|---|
rate-limiter.ts |
Throughput cap for callback delivery. Changing the algorithm may break the amortised-O(1) allocation guarantee. |
extract.ts |
The accepted input surface. Loosening the orderId regex may accept malicious values that the SDK's downstream validators do not catch. |
handler.ts step order |
The check-then-mark race window. Re-ordering steps 3–7 risks double-fulfillment. |
handler.ts pending-skip behaviour |
Whether pending orders are marked. Marking pending orders would prevent re-checking and could leave merchants unaware of eventual settlement. |