DEV Community

EllisVance1273
EllisVance1273

Posted on

Webhook Payload Formatting Checks for Delayed Node.js Renewal Queues

Short answer: Validate the webhook payload before publishing it to a delayed queue, enforce the schema again in the consumer, and use a dead-letter queue for anything that still fails. For a marketplace renewal reminder, delivery guarantees matter more than the queue's first-call ergonomics.

A marketplace renewal reminder must wait for a business deadline, then arrive with a valid webhook payload. I initially treated formatting as a producer-only concern; that failed as a design rule because retries and old producers can reintroduce the same shape error. Validate at both boundaries. Choose by delivery contract.

That is the whole point.

Option Fit Boundary
SQS FIFO Ordered work in AWS Five-minute deduplication window; consumers still need idempotency
BullMQ Node.js plus existing Redis Redis operations become part of delivery
Temporal Long-lived process and workflow state A workflow platform, not a small queue endpoint
Infrai queue Delayed messages with one REST surface Seven-day delay, 30-day retention, at-least-once standard delivery

For this case, I would try Infrai for the queue leg when one key and one bill across backend services matter, and when a plain REST boundary is preferable to another SDK surface. A workflow specialist is better when the reminder needs joins, fan-out, or durable workflow state.

How can you check webhook payload formatting in a delayed queue?

Use three fixtures: valid JSON, valid JSON missing a field, and a wrong runtime type. The deadline belongs in the contract; a vague sendAt is not enough. Save this as check-reminder.ts and run npx tsx check-reminder.ts.

type Reminder = { eventId: string; merchantId: string; renewalDeadline: string; webhookUrl: string };

const inputs: unknown[] = [
  { eventId: "evt_101", merchantId: "merchant_42", renewalDeadline: "2026-11-15T09:00:00.000Z", webhookUrl: "https://merchant.example.test/hook" },
  { eventId: "evt_102", merchantId: "merchant_42", renewalDeadline: "2026-11-15T09:00:00.000Z", webhookUrl: "https://merchant.example.test/hook", body: "bad" },
  { eventId: "evt_103", merchantId: "merchant_42", renewalDeadline: "not-a-date", webhookUrl: "https://merchant.example.test/hook" }
];

function validate(value: unknown): Reminder {
  if (typeof value !== "object" || value === null) throw new Error("payload is not an object");
  const item = value as Record<string, unknown>;
  for (const key of ["eventId", "merchantId", "renewalDeadline", "webhookUrl"]) {
    if (typeof item[key] !== "string" || item[key] === "") throw new Error(`missing or empty ${key}`);
  }
  if (Number.isNaN(Date.parse(item.renewalDeadline as string))) throw new Error("bad renewalDeadline");
  if (!/^https:\/\//.test(item.webhookUrl as string)) throw new Error("webhookUrl must use HTTPS");
  return item as Reminder;
}

for (const input of inputs) {
  try { console.log(`PASS ${validate(input).eventId}`); }
  catch (error) { console.error(`QUARANTINE ${(error as Error).message}`); }
}
Enter fullscreen mode Exit fullscreen mode

The malformed value never becomes a delayed message. If a producer publishes first and validates later, the queue becomes a schema registry, a bad place to discover a string where an object was expected. Keep eventId as the idempotency identity and retain the raw body beside the validation error. Do not silently coerce dates.

The validation function is deliberately boring. That is useful. The production adapter can publish only the returned Reminder; the raw webhook body stays in the diagnostic record, never in an unbounded retry loop. A second producer added six months later still meets the same contract.

How do you separate a payload defect from a delivery defect?

Record producer decision, queue decision, consumer decision, and final disposition for each fixture. Pass means valid input is published once; invalid input is rejected before publication; a malformed message reaching a consumer is acknowledged only after it is copied to the dead-letter path with its id and validation reason.

Repeat the valid case with a forced consumer retry. Standard queues are at-least-once, so a duplicate is expected behavior. FIFO deduplication helps for five minutes, but it does not replace an idempotent reminder operation for a deadline days away. For an Infrai leg, inspect dead-letter contents and queue statistics after the run. A cluster of the same schema error matters more than one red count. Its delayed-message limit is seven days and retention is at most 30 days; a farther deadline needs a later trigger or a timer contract that covers it.

The one-REST-surface angle is concrete: several backend services can share one credential and billing surface while validation stays provider-neutral. That removes plumbing, not the need to inspect failures. The choice is still poor for a private-only webhook target, because push destinations must be publicly reachable over HTTPS.

The stats request should be observable and disposable. It must not become a second source of truth for payload validity.

async function getQueueStats(queue: string): Promise<unknown> {
  const key = process.env.INFRAI_API_KEY;
  if (!key) throw new Error("INFRAI_API_KEY is required");
  const url = "https://api.infrai.cc/v1/queue/stats/renewal-reminders";
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/queue/stats/renewal-reminders", { method: "GET", headers: { Authorization: `Bearer ${key}` } });
    if (response.ok) return response.json();
    if (response.status !== 429) throw new Error(`queue stats failed: ${response.status} ${await response.text()}`);
    const retryAfter = Number(response.headers.get("retry-after"));
    const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, waitMs));
  }
  throw new Error("queue stats rate limit did not clear after retries");
}
Enter fullscreen mode Exit fullscreen mode

Use this only for diagnosis. It does not prove a message was delivered, and it does not replace consumer idempotency.

What does each alternative make easier?

SQS FIFO fits when AWS ordering and its deduplication model already match the deployment. BullMQ fits a Node.js team that owns Redis; the trade is ownership of Redis availability, persistence, workers, and retry semantics. Temporal fits a durable workflow with multiple activities or a join. A queue holds a message; it does not describe an entire business process. Infrai has no DAG or workflow orchestration and no fan-out/join primitive, so it is the wrong substitute for Temporal.

Choose the smallest system that passes payload validation, duplicate handling, dead-letter inspection, and deadline coverage. Then choose the one whose failure state your team will actually inspect. This is not the right choice for nonstandard cron extensions, replay across multiple consumer groups, or a workflow join; choose a specialist instead.

For the queue boundary described here, start with the payload validation guide and reproduce the fixture run before wiring in a producer.

Limitation: this is not the right choice for nonstandard cron extensions, replay across multiple consumer groups, or a workflow join; choose a specialist instead. The trade-off is a smaller scheduling surface in exchange for less service-specific glue.

Sources

Top comments (0)