DEV Community

FluxH91
FluxH91

Posted on

Payment Reconciliation: Node.js Delayed Webhook Task Queue Retry Design

Short answer: for a nightly healthtech payment reconciliation, put each delayed webhook in durable queue state, make the public HTTPS receiver idempotent, and treat a five-minute retry as a delivery policy rather than a promise of exact timing.

That answer is less exciting than choosing a queue library, but it is the decision that protects the ledger. A payment provider can accept a request while the connection disappears before the sender sees the response. A worker can be terminated after the remote effect and before its acknowledgement. In both cases, the next delivery is ambiguous. The queue moves work; it does not turn an at-least-once transport into exactly-once business effects.

Duplicates are expected.

Keep it boring.

For this architecture, the invariants are stable: one logical reconciliation event has one idempotency key, one durable payload reference, an attempt number, and a state owned by the application. The receiver records that key with the effect it commits. The scheduler records enough information to explain what happened. Those records are the audit trail; the message is a request to try the next delivery.

What must a delayed webhook queue guarantee for payment reconciliation?

Start by naming the failure boundaries. A nightly reconciliation job may discover a missing payment-provider event, create a delivery record, enqueue a webhook, and then wait five minutes before the first or next attempt. Each transition needs an owner and a durable observation. If the process dies between two transitions, recovery should find a record that can be retried or examined, rather than a timer that vanished with the process.

The receiver should use the idempotency key as a durable uniqueness boundary. On the first request, it validates the payload, claims the key, performs its local transaction, and stores the outcome. On a repeat request, it returns the recorded outcome without applying the payment effect again. A memory-only set is not enough: it disappears during a restart and says nothing to a second worker running on another instance.

There is a small but important distinction between a transport result and a business result. A 2xx response can mean the receiver accepted the event, but a dropped connection can leave the sender unable to prove that. A timeout does not prove that the receiver did nothing. Retrying is usually the safer delivery choice, provided the receiver can recognize the key. A non-retryable validation response should reach a terminal state with an operator-visible reason; otherwise the queue becomes a quiet loop for bad data.

Consider one reconciliation row for a payment that the provider says was captured but the internal ledger has not recorded. The nightly job creates capture-1842, stores the provider snapshot, and schedules the first delivery for five minutes later. The public endpoint reads the event, commits the ledger update, and then loses the connection before the queue consumer receives its acknowledgement. The queue has no reliable way to infer whether the ledger write happened. A second delivery with a new key could create a second ledger entry; a second delivery with the original key lets the receiver return the already-recorded outcome. If the receiver has no durable key table, the sender must surface the event for reconciliation instead of pretending that a response code resolved the ambiguity. This is why the idempotency record and the business effect need a defined transaction boundary, while the queue only needs to know when it may settle the current message.

The five-minute value belongs in policy, not in a setTimeout held by a Node.js process. A deploy, crash, or autoscaling replacement can erase an in-process timer. Persist next_attempt_at, and let a durable scheduler or queue release the job when it is eligible. “After five minutes” should also have a defined tolerance, since scheduler polling and worker availability affect the actual start time.

Decision What it gives the reconciliation path Boundary the team still owns Choose another shape when
Durable delayed queue Release of a message after an eligibility time and a retryable work unit Duplicate delivery, poison messages, terminal state, and receiver idempotency The work needs joins, long-running workflow state, or a replayable event log
Database outbox plus poller Atomic creation of business state and an outbound delivery record Leases, polling indexes, concurrent claims, cleanup, and backoff The application does not already treat its database as the delivery source
Broker with explicit acknowledgements Fine-grained ownership of settlement and redelivery Broker operation, topology, consumer lifecycle, and message policy Operating a broker adds more responsibility than this delivery path warrants
In-process timer Very little setup for a disposable local action Lost schedules, process lifetime, and duplicate recovery The event affects a payment record or must survive deployment

That table is an architecture decision record in miniature. The rejected option is the in-process timer. It is valid for a best-effort reminder inside a short-lived script, but it is not a durable scheduling mechanism for reconciliation.

How should a Node.js worker handle a 5-minute retry at a public HTTPS endpoint?

Keep the queue contract narrow. The message can carry a logical key, a payload reference, a target URL, and the attempt number. Large payment details belong in a durable application store, with access control and retention chosen for the data, rather than being copied into every transport message. The public endpoint should authenticate the request, validate the schema, and pass the same key into its idempotency transaction.

The following Python example shows the critical path without binding the design to a queue vendor. The queue adapter is intentionally an application boundary: its implementation must preserve the same fields and the same settlement rule. The worker acknowledges only after it has a durable result, and schedules a new delivery when the remote outcome is classified as retryable.

import json
import os
import urllib.error
import urllib.request


DELIVERY_URL = "https://public.example.org/payment-events"
RETRY_DELAY_SECONDS = 300


def post_webhook(delivery):
    body = json.dumps({
        "event_id": delivery["event_id"],
        "payload_ref": delivery["payload_ref"],
        "attempt": delivery["attempt"],
    }).encode("utf-8")
    request = urllib.request.Request(
        DELIVERY_URL,
        data=body,
        method="POST",
        headers={
            "Authorization": "Bearer " + os.environ["WEBHOOK_TOKEN"],
            "Content-Type": "application/json",
            "Idempotency-Key": delivery["idempotency_key"],
        },
    )
    try:
        with urllib.request.urlopen(request, timeout=20) as response:
            return response.status, response.read().decode("utf-8")
    except urllib.error.HTTPError as error:
        return error.code, error.read().decode("utf-8", errors="replace")


def deliver(queue, delivery_store, delivery):
    status, response_body = post_webhook(delivery)
    delivery_store.record_observation(
        key=delivery["idempotency_key"],
        status=status,
        response=response_body,
    )

    if 200 <= status < 300:
        queue.acknowledge(delivery["message_id"])
        return

    if status in {408, 429} or status >= 500:
        next_delivery = dict(delivery)
        next_delivery["attempt"] += 1
        queue.enqueue(
            next_delivery,
            delay_seconds=RETRY_DELAY_SECONDS,
        )
        queue.acknowledge(delivery["message_id"])
        return

    delivery_store.mark_terminal(
        key=delivery["idempotency_key"],
        reason="non-retryable HTTP response",
    )
    queue.acknowledge(delivery["message_id"])
Enter fullscreen mode Exit fullscreen mode

The ordering in this sketch is deliberate, but it is not a complete idempotency implementation. record_observation must be backed by a transaction that can distinguish the first key claim from a replay, and the local business effect must be committed according to the receiver's own transaction model. If the endpoint does not control that effect, it cannot honestly promise duplicate-free behavior merely because it sends an Idempotency-Key header.

The retry classification also belongs to the destination contract. A 429 commonly indicates that waiting may help, while a 400 usually calls for correction or inspection; the application should document its exact policy instead of treating every non-2xx response as equivalent. I’m not sure a fixed five-minute delay is right for every payment provider, and your mileage will vary with rate limits, reconciliation volume, and the provider’s retry guidance. Make that uncertainty visible in configuration and metrics.

A public HTTPS endpoint is an exposure boundary, not a guarantee of availability. Use authentication, request-size limits, schema validation, replay detection, and a bounded retry budget. Log the logical key, attempt, destination class, and correlation identifier without logging sensitive payment data. The endpoint should answer quickly after it has accepted the work it can durably own; long synchronous processing increases the chance that a sender retries while the first request is still running.

Which delivery guarantees should the nightly reconciliation record?

The scheduler needs more than a success counter. For each logical event, record discovery time, scheduled time, attempt number, last observed status, next attempt time, terminal reason, and the payload reference. Keep the original idempotency key stable across attempts. Generating a new key for every retry defeats the receiver’s duplicate check and turns a transport retry into a new business event.

The most useful operational distinction is between “not attempted,” “attempt outcome unknown,” and “known terminal failure.” An unknown outcome is the ambiguous network case: the request may have committed remotely. It should be retried with the same key, then reconciled against the receiver’s stored result or an authoritative payment-provider record. A terminal failure is different; it needs a queue acknowledgement plus an alert or review workflow, not infinite redelivery.

Test those states with controlled failure injection. Stop the worker after the remote request but before acknowledgement. Return a timeout after the receiver commits. Deliver the same message concurrently to two workers. Restart the process while a job is waiting. The expected result is one business effect, an explainable delivery record, and a message settlement that does not hide the failure. A happy-path test does not exercise the dangerous boundary.

RabbitMQ’s acknowledgement and publisher-confirm documentation is a useful vocabulary for this review, even when the chosen queue is different: ask what confirms publication, what confirms processing, and what happens when either confirmation is lost. Priority queues can help an urgent reconciliation move ahead of ordinary work, but priority is not fairness, durability, or idempotency. Those properties must be specified separately.

Watch for queue age, attempt count, retry delay, terminal failures, unknown outcomes, and duplicate-key responses. A dashboard that shows only throughput can look healthy while payment events accumulate behind a bad destination response. Alerting should follow the business invariant, such as unreconciled events beyond an agreed age, rather than only following worker CPU or request count.

When is this delayed webhook design the wrong choice?

The catch is that a delayed queue is a delivery primitive, not a workflow engine. If the nightly process requires fan-out and joins, compensation across several activities, or a durable event history that can be replayed after acknowledgement, choose an architecture that makes those requirements first-class. Adding more ad hoc queues can imitate a workflow while leaving state transitions implicit and difficult to audit.

It is also a poor fit when the receiver cannot implement an idempotency boundary and the effect is not safely repeatable. In that case, changing the retry interval does not solve the fundamental ambiguity. Prefer a provider operation with an explicit idempotency contract, or put a transactionally controlled intermediary in front of the effect; the right answer depends on who owns the authoritative payment state.

A database outbox is a reasonable choice when the business transaction already creates the outbound event in the same database. It gives the application a clear write boundary, but it shifts responsibility to lease expiry, polling, indexes, cleanup, and contention. A broker with acknowledgements is reasonable when routing and consumer ownership are central, but its operational surface is larger. These are trade-offs, not rankings.

The decision rule is plain: select the smallest durable mechanism that can preserve the logical key, delay eligibility, record ambiguous outcomes, and make terminal failures visible. For a healthtech payment reconciliation, those guarantees matter more than whether the worker is written in Node.js or Python, and more than whether “five minutes” appears as a convenient setting in a dashboard.

References

Further reading

Top comments (0)