DEV Community

RivenPulse5812
RivenPulse5812

Posted on

Daily Report Cron Job API Scheduling: 2 HTTP Webhook Delivery Guarantees for Payment Reconciliation

Short answer: choose the scheduler by the delivery guarantee you can prove, not by how friendly its cron editor looks. For a healthtech payment reconciliation, the useful contract is usually at-least-once execution plus an idempotent report key. A webhook should wake the job. It should not be treated as proof that the email was sent.

That distinction changed my design. A daily report can be late and still be useful. A reconciliation that silently skips a provider window is a bookkeeping defect. I would schedule one run for 02:00 UTC, persist an immutable run record, fetch the payment window, build the report, and hand off email work with a durable acknowledgement path. The schedule is the trigger. The ledger is the authority.

The trigger is not the record.

Keep that boundary boring.

How should a cron job API schedule a daily report?

There are two different promises hiding in the word “reliable.” First, the scheduled task is attempted. Second, the resulting report is accepted by the next system. A cron endpoint can satisfy the first promise while the network drops the response after the server has started work. Retrying the webhook can then create a duplicate run.

For this job, I would write the contract like this:

Boundary Contract Evidence to retain
Scheduler to worker At least one delivery attempt Request ID, scheduled time, response status
Worker to reconciliation store One logical run per business date Unique run key and final state
Report builder to email queue At least one accepted message Message ID and queue confirmation
Queue to consumer Acknowledge after durable handoff Consumer acknowledgement and retry history

“Exactly once” is a poor label for a chain that includes HTTP and email. Use an idempotency key instead. For example, payment-reconciliation:2026-10-24 identifies the business date, while a separate attempt ID identifies one delivery attempt. If the webhook arrives three times, all three attempts point at one logical run.

RabbitMQ’s acknowledgement model makes the important boundary visible: a consumer can acknowledge only after it has handled the message, and an unacknowledged message may be delivered again. That is a recovery behavior, not a bug. The handler must make repeated delivery harmless.

The smallest working implementation

The implementation below keeps scheduling concerns outside the worker. The worker accepts a generic HTTP request, validates a shared secret, claims a logical run, and publishes a report command. The storage and queue interfaces are intentionally boring. Boring interfaces expose the glue you will have to own.

type RunState = "claimed" | "published" | "completed";

type ReconciliationRun = {
  key: string;
  attemptId: string;
  state: RunState;
};

interface RunStore {
  claim(key: string, attemptId: string): Promise<ReconciliationRun | null>;
}

interface ReportQueue {
  publish(command: { runKey: string; businessDate: string }): Promise<void>;
}

export async function handleReconciliationWebhook(
  request: Request,
  store: RunStore,
  queue: ReportQueue,
  expectedSecret: string,
): Promise<Response> {
  if (request.headers.get("authorization") !== `Bearer ${expectedSecret}`) {
    return new Response("unauthorized", { status: 401 });
  }

  const body = (await request.json()) as { businessDate?: string; attemptId?: string };
  if (!body.businessDate || !body.attemptId) {
    return new Response("businessDate and attemptId are required", { status: 400 });
  }

  const runKey = `payment-reconciliation:${body.businessDate}`;
  const run = await store.claim(runKey, body.attemptId);

  if (run === null) {
    return new Response(JSON.stringify({ runKey, status: "already-claimed" }), {
      status: 202,
      headers: { "content-type": "application/json" },
    });
  }

  await queue.publish({ runKey, businessDate: body.businessDate });
  return new Response(JSON.stringify({ runKey, status: "accepted" }), {
    status: 202,
    headers: { "content-type": "application/json" },
  });
}
Enter fullscreen mode Exit fullscreen mode

The unique constraint behind claim matters more than the handler syntax. It should atomically create the run key or return the existing record. Do not check for a row and insert later; two concurrent webhook deliveries can both pass that check.

The response is 202 because acceptance means the command was recorded for processing, not that an email reached a mailbox. A scheduler can retry a timeout. The run ledger can distinguish a retry from a new business date. Those are different operations and should stay visible in logs.

Which scheduler and webhook shape fits the risk?

I would compare candidates on five concrete questions rather than on feature-count tables. Can the trigger send a stable attempt ID? Can it retry with a documented policy? Can it expose the scheduled timestamp separately from the actual delivery timestamp? Can it sign requests or support a secret that can be rotated? Can the team export enough history to explain a missed report six weeks later?

The last question catches a common trap. A green execution chart may mean only that an HTTP request got a successful response. It says nothing about whether the payment provider returned a complete window, whether the report was queued, or whether the email consumer acknowledged its message. Instrument those stages separately.

I would also test the scheduler’s failure behavior with a deliberately slow endpoint. Send a 504, close the connection after accepting the body, and return a 200 before the downstream publish completes. Each case answers a different question about retries. A “retry enabled” checkbox is not a delivery specification.

The slow-endpoint test is worth spelling out because it exposes a subtle ordering failure. Suppose the worker claims the business-date key, starts publishing, and then loses its network connection before the scheduler sees the response. The scheduler retries. The second request finds the existing claim, but the first publish may still be in flight. If the claim and publish are treated as one atomic action when they are not, the system can report “already claimed” while no durable message exists. The remedy is an explicit state transition with a recovery scan for claimed-but-not-published runs. That scan is a small operational cost, and it is far easier to reason about than pretending HTTP gives you a transaction.

Avoid putting the whole reconciliation in the webhook request. Healthtech reports often touch external payment data, a database, and an email system. The request should authenticate, validate, claim, and enqueue. The worker should own provider pagination, schema checks, report rendering, and email submission. That split keeps a scheduler timeout from deciding whether the business operation happened.

What I would change at scale

At a few thousand accounts, I would add a reconciliation watermark per provider and a dead-letter path for messages that fail after the retry budget. The report would include the provider window, row counts, unmatched transaction count, and run key. Operators need those facts before they open logs.

I would keep the daily schedule fixed and make recovery an explicit command. A replay for October 24 should carry the same business-date key but a new attempt ID. That lets the system repair a failed handoff without creating a second accounting day. It also makes audit reviews less theatrical: one logical run, many attempts, one final disposition.

The trade-off is deliberate. More state means more tables, metrics, and retention rules. In exchange, the system can explain the difference between “the scheduler never called us,” “the worker rejected the payload,” “the queue redelivered it,” and “the email provider accepted it.” For payment reconciliation, that explanation is part of the feature.

Start with the guarantee you need: at-least-once trigger delivery, idempotent business processing, and an observable handoff to email. Then test the boundaries under timeout, duplicate delivery, partial provider data, and consumer restart. The best cron job API is the one whose failure semantics you can demonstrate with those tests, not the one with the shortest setup guide.

Further reading

Top comments (0)