DEV Community

dawn li
dawn li

Posted on

Order Receipt Status Checks Explained — Email Events API Without Webhooks

Short answer: after an edtech payment settles, write one durable receipt record, send once with an idempotency key, and let a scheduled reconciler poll the email events API until that record reaches a terminal state; without webhooks, delivery reliability comes from durable state, bounded retries, and an explicit evidence-retention policy, not from running cron more often.

Start with the bill. A polling design pays mainly for repeated status reads while a receipt remains unresolved, plus database writes and retained event evidence. The useful estimate is daily status reads = cron runs per day × ceil(open receipts / API batch size). If an API only permits lookup by message ID, replace the batch term with open receipts; that interface difference can dominate every other cost discussion. Retained storage is events per receipt × average event bytes × receipts × retention days. Put measured values from a staging trace into those equations before choosing an interval. I’m not sure there is a defensible universal polling interval, because settlement volume, provider limits, and the promised support window decide it.

The change that usually moves the dominant term is simple: poll pending receipts quickly at first, then back off, and stop polling terminal records. Keep normalized transitions and request metadata long enough for the organization’s audit and support obligations; deliberately expire raw response bodies sooner. The catch is lost forensic detail: after raw evidence expires, an unusual provider-side transition is harder to reconstruct.

What does Node.js cron email status polling cost without webhooks?

There is no honest fixed price without the API contract and a measured open-receipt curve. The cost-bearing unit is the status read, so the cheapest read is the one the worker can prove it no longer needs: terminal receipts leave the active set, a batch endpoint amortizes a request across open receipts, and backoff spends fewer reads on old pending records. Storage behaves differently. A compact normalized transition is cheap to retain relative to an unbounded raw body, yet deleting everything at terminal state makes later support questions unanswerable. The correct budget therefore has two axes, request volume and evidence days, and both should be calculated from observed staging data rather than a round interval chosen because cron syntax makes it convenient.

The cron process should poll a durable work table, not the payments table and not an in-memory queue. Each row represents an obligation created after payment settlement: one order, one intended recipient, one logical receipt, the provider message identifier returned by the send operation, the current normalized state, the next check time, an attempt count, and a short lease. A Node.js service can own that worker in production; the state machine below is deliberately shown in Python because the mechanism is runtime-independent.

This separation matters. Payment settlement authorizes the receipt. A successful send request only establishes that the email system accepted a request under its own contract. A later event may establish a terminal outcome. Treating those three observations as one boolean creates a dangerous ambiguity: on a timeout, the application can’t tell whether it should send again or merely check the existing message.

Use two idempotency boundaries. The first is a unique database key such as (order_id, message_kind) so two settlement consumers cannot create two logical receipts. The second is the stable key passed to the send adapter, if its API supports that capability. Persist the returned provider message ID in the same controlled transition that marks the obligation as submitted. Don’t generate a fresh key on retry.

Send once.

There is one awkward boundary here — the remote send and the local commit cannot normally share a database transaction. A process can stop after the remote side accepts the message but before the local record stores its ID. An outbox narrows the ambiguity, while an idempotent send operation resolves it. If the chosen provider does not offer idempotent submission or lookup by the application’s stable key, this polling design cannot by itself prove that a retry will avoid a duplicate. That is a capability limit, not an implementation detail.

Retention defines the evidence contract

Normalize provider-specific events at the adapter boundary. The core reconciler only needs pending, terminal_success, and terminal_failure, together with an event identifier, provider timestamp, observed timestamp, and a cursor when the upstream API supplies one. Preserve the unrecognized upstream status in bounded metadata so a new status does not silently become success.

Order events by the provider’s documented ordering token when one exists. Arrival time at the worker is not a trustworthy substitute: two cron runs may overlap, pages may be replayed, and an older event may be observed after a newer one. Deduplicate on the provider event ID. Then apply a monotonic transition rule so a late pending observation cannot reopen a terminal receipt.

Decision Reliable default Limitation that changes the choice
Work ownership Database lease with an expiry Use a queue lease when the database cannot absorb claim traffic
Event ingestion Cursor or event-ID deduplication Per-message lookup may be the only available API shape
Retry timing Fast initial checks, then capped backoff with jitter A strict receipt-status deadline may require a different channel
Evidence Normalized transitions plus bounded raw metadata Regulated or disputed payments may require longer immutable retention
Terminal failure Record, alert by policy, and stop automatic resend Human-approved resend may be appropriate after an address correction

Be precise about “delivered.” It is a provider-defined event label, not proof that a learner read the receipt, and it is not a substitute for the order ledger. The order page should remain the source of truth for the purchase. Email is a notification path.

Keep those claims separate.

Test the lease before trusting the loop

The useful cron example is the transition boundary, not the scheduler syntax. The following core assumes that store.claim_due atomically leases rows, events.fetch implements the selected email events API, and store.apply inserts each event idempotently while enforcing monotonic states. Those contracts should be backed by database constraints, not hopeful comments.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from random import uniform
from typing import Protocol


@dataclass(frozen=True)
class Receipt:
    receipt_id: str
    provider_message_id: str
    attempt: int


@dataclass(frozen=True)
class Event:
    event_id: str
    state: str
    occurred_at: datetime


class Store(Protocol):
    def claim_due(self, now: datetime, limit: int, lease: timedelta) -> list[Receipt]: ...
    def apply(self, receipt: Receipt, events: list[Event], checked_at: datetime) -> str: ...
    def reschedule(self, receipt_id: str, next_check: datetime, reason: str) -> None: ...


class EventsAPI(Protocol):
    def fetch(self, provider_message_id: str) -> list[Event]: ...


def delay_for(attempt: int, base_seconds: int, cap_seconds: int) -> timedelta:
    ceiling = min(cap_seconds, base_seconds * (2 ** attempt))
    return timedelta(seconds=uniform(ceiling / 2, ceiling))


def reconcile(
    store: Store,
    events: EventsAPI,
    now: datetime,
    batch_size: int,
    lease_seconds: int,
    base_delay_seconds: int,
    max_delay_seconds: int,
) -> None:
    receipts = store.claim_due(now, batch_size, timedelta(seconds=lease_seconds))
    for receipt in receipts:
        try:
            observed = events.fetch(receipt.provider_message_id)
            state = store.apply(receipt, observed, checked_at=now)
            if state == "pending":
                delay = delay_for(receipt.attempt, base_delay_seconds, max_delay_seconds)
                store.reschedule(receipt.receipt_id, now + delay, "still_pending")
        except TimeoutError:
            delay = delay_for(receipt.attempt, base_delay_seconds, max_delay_seconds)
            store.reschedule(receipt.receipt_id, now + delay, "transport_timeout")


if __name__ == "__main__":
    run_at = datetime.now(timezone.utc)
    reconcile(store, events_api, run_at, batch_size, lease_seconds,
              base_delay_seconds, max_delay_seconds)
Enter fullscreen mode Exit fullscreen mode

Configuration values are intentionally not invented here. Set the batch size below the documented API limit, set the lease longer than a measured high-percentile run but shorter than the operational recovery window, and derive retry bounds from the receipt-status objective. On HTTP 429, respect the API’s documented retry signal when available and reschedule the row; don’t spin inside the leased worker. Authentication failures and malformed responses belong on a dead-letter or operator-review path because rapid retries won’t repair credentials or a contract mismatch.

The code catches a transport timeout because its outcome is uncertain and therefore retryable as a status read. It does not automatically resend the receipt.

That distinction is the design.

Run one process from cron if a single batch always finishes comfortably before the next start. When throughput requires concurrent workers, atomic claiming and expiring leases prevent duplicate work; event-ID uniqueness makes a repeated page harmless. A scheduler-level “no overlap” flag is still useful, but it isn’t sufficient evidence of exclusivity after a host restart or a long pause.

The happy path proves almost nothing. Freeze time in tests and simulate an empty event list, the same page twice, events arriving out of order, a timeout after the upstream response, an HTTP 429, a worker stopping after claim, lease expiry, and two workers racing for the same row. Assert database state and the next-check timestamp after every case. A property test can generate event permutations and verify that terminal state never moves backward. Test the send boundary separately: duplicate settlement messages must produce one logical receipt obligation. If the adapter offers idempotent submission, replay the same stable key and verify the documented behavior in a non-production environment. If it doesn’t, stick with operator review for ambiguous submission outcomes rather than pretending automatic retries are safe. Observability should follow obligations, not process activity: track the age of the oldest unresolved receipt, counts by normalized state, claim-to-completion latency, API request outcomes, retry delay, and lease recoveries. A cron job that logs “ran successfully” while old receipts remain pending is operationally green and product-wise wrong. Alert on the promised outcome window and on a growing unresolved cohort; raw request volume is diagnostic, not the service objective.

Roll out deletion with the worker

Deploy schema constraints before enabling concurrent workers. Roll out the adapter’s normalization rules with fixtures captured from documented response shapes, then canary the reconciler against a small cohort. Keep logs free of message bodies and unnecessary recipient data. For compliance, classify the receipt’s primary purpose and review content and routing practices against applicable rules; the FTC’s CAN-SPAM guide is a useful US starting point, but legal scope depends on the actual message and jurisdiction.

Polling is not suitable when the business requires near-immediate status changes but the events API imposes a cadence or rate limit that cannot meet that objective. Use authenticated webhooks when they are available and operationally supportable, while retaining periodic reconciliation for missed notifications. Stick with a managed queue or change stream when status evidence already lands inside infrastructure the team controls.

It is also a poor fit when the upstream service exposes neither stable message lookup nor an ordered event feed. No amount of cron tuning can manufacture correlation. In that case, select a channel or provider contract that exposes the evidence the reliability target requires, or weaken the target explicitly.

Keep less data on purpose. After a receipt reaches a terminal state and its audit window closes, delete raw event bodies and request traces while retaining the minimum normalized record required by policy. This reduces storage and sensitive-data exposure, but it costs diagnostic depth during a late dispute. Write that trade-off into the retention schedule instead of discovering it during an incident.

References

Further reading

Top comments (0)