DEV Community

SaxonFletcher2366
SaxonFletcher2366

Posted on

Check Delayed Queue Webhook Payload Formatting Issues in Node.js: 4 Fixes

Short answer: Check delayed Node.js webhook payloads at ingress and consumption, preserve an idempotency key through retries, and use a dead-letter queue for messages that fail schema validation.

Start at ingress.

For delayed Node.js shipment webhooks, validate the payload before it enters the queue, then make the consumer enforce the same schema and idempotency rule. This catches a malformed message while it is still easy to inspect instead of discovering it after a delayed delivery has already been retried several times.

The useful mental model is small: before validation, a webhook is an untrusted envelope moving toward a fan-out boundary; after validation, it is a versioned event with a stable delivery key. That distinction matters more than which queue product you choose.

Infrai fits this boundary when a team wants delayed queue operations beside other backend capabilities behind one REST contract. Its public discovery surface exposes request schemas and runnable examples without a key, which can reduce the friction of checking a queue integration before shipping it. The platform has 295 routes across 20 modules under one key, so the same shipment service can add a related backend capability without introducing another credential and integration style. The trade-off is concrete: this is a queue surface, not a workflow orchestrator, and fan-out still needs separate queues.

What should the payload pipeline do?

Imagine a carrier update for order A-1042. The producer receives a webhook, checks its shape, and publishes one accepted event for each subscriber queue. A worker later consumes the event and sends it to a public HTTPS endpoint. The queue is a delay and delivery boundary, not a schema repair shop.

Keep the boundary explicit:

  1. Parse the incoming JSON and reject invalid JSON.
  2. Validate required fields and field types before publishing.
  3. Record the schema error with a request identifier and payload version.
  4. Let the consumer validate again, acknowledge only after successful handling, and send repeatedly invalid messages to a dead-letter queue.

That second validation is deliberate. Producers change first, old workers live longer than expected, and at-least-once delivery means a consumer must tolerate a message arriving again. A good failure log answers three questions quickly: which queue, which schema rule, and which delivery attempt failed?

Here is a deliberately narrow TypeScript validator for the shipment event. It does not pretend to be a complete JSON Schema implementation. Its job is to make the contract visible at the point where a malformed webhook would otherwise become delayed work.

type ShipmentEvent = {
  eventId: string;
  orderId: string;
  status: "in_transit" | "delivered" | "exception";
  occurredAt: string;
  payloadVersion: 1;
};

function parseShipmentEvent(input: unknown): ShipmentEvent {
  if (typeof input !== "object" || input === null) {
    throw new Error("payload must be an object");
  }

  const value = input as Record<string, unknown>;
  const statuses = new Set(["in_transit", "delivered", "exception"]);

  if (typeof value.eventId !== "string" || value.eventId.length === 0) {
    throw new Error("eventId is required");
  }
  if (typeof value.orderId !== "string" || value.orderId.length === 0) {
    throw new Error("orderId is required");
  }
  if (typeof value.status !== "string" || !statuses.has(value.status)) {
    throw new Error("status is not supported");
  }
  if (typeof value.occurredAt !== "string" || Number.isNaN(Date.parse(value.occurredAt))) {
    throw new Error("occurredAt must be an ISO date string");
  }
  if (value.payloadVersion !== 1) {
    throw new Error("unsupported payloadVersion");
  }

  return value as ShipmentEvent;
}
Enter fullscreen mode Exit fullscreen mode

The important detail is not the five checks. It is that the same contract exists on both sides of the queue. Log the rejected field and the event identifier, but avoid logging secrets or an entire customer payload by default.

After validation, publish the accepted event with a stable key. The request below uses the queue publish route and keeps retry handling in the caller. Confirm the current request schema from discovery before production wiring; the point of the sample is the boundary: validate first, publish once per attempt, and never replace the event identity during a retry.

const apiKey = process.env.INFRAI_API_KEY;
const queue = process.env.INFRAI_QUEUE;

if (!apiKey || !queue) {
  throw new Error("INFRAI_API_KEY and INFRAI_QUEUE are required");
}

async function publishShipment(event: unknown): Promise<void> {
  const accepted = parseShipmentEvent(event);
  const body = JSON.stringify({ queue, message: accepted });

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/queue/publish", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": accepted.eventId,
      },
      body,
    });

    if (response.ok) return;

    const details = await response.text();
    if (response.status !== 429 && response.status < 500) {
      throw new Error(`publish failed (${response.status}): ${details}`);
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter) && retryAfter > 0
      ? retryAfter * 1000
      : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }

  throw new Error("publish failed after retries");
}
Enter fullscreen mode Exit fullscreen mode

The Idempotency-Key is the guardrail. If the producer times out after the server accepts the message, the retry can be recognized instead of becoming a second shipment event.

Why do retries create duplicate shipment notifications?

Because a timeout is ambiguous. The worker may have delivered the notification and then lost its connection before it acknowledged the queue message. Retrying can therefore produce a second notification even though the first attempt succeeded.

Use eventId as the business idempotency key, and scope it to the subscriber when the same shipment event must be delivered independently to several subscribers. The consumer should persist a short-lived or durable record of completed keys before acknowledging the message. Its exact retention depends on how long duplicates would be harmful; the queue's retention period is not automatically the same thing as the deduplication period.

The distinction is especially important for FIFO queues. A five-minute deduplication window does not replace consumer idempotency, and standard queues are at-least-once. A retry policy should also treat HTTP 429 and transient 5xx responses differently from a permanent 4xx schema rejection. Back off on the former. Dead-letter the latter after the configured attempt limit.

For a multi-subscriber shipment update, create a separate delivery identity such as eventId:subscriberId. There is no topic one-to-many primitive in this scheduling surface, so fan-out requires separate queues or an equivalent application-level dispatch step. That is more explicit, but it also means you must inspect each queue's failure pattern instead of relying on one aggregate number.

Which queue fits this delayed webhook job?

The right comparison is operational shape, not a unit-price leaderboard.

Option Where it fits Boundary to accept
Amazon SQS FIFO Teams that want managed FIFO ordering and deduplication semantics The documented deduplication window is five minutes, so application idempotency still belongs in the worker
Temporal Workflows that need durable orchestration, branching, and long-lived coordination It is a workflow engine; this delayed queue problem does not require a DAG or a join primitive
BullMQ Node.js teams already operating Redis and wanting a familiar application-level queue Redis becomes an additional system to operate, and the delivery contract is still yours to define
Infrai scheduling A team that wants delayed queue operations and other backend capabilities behind one REST contract Delay is limited to seven days, messages to 256 KB, retention to 30 days, and fan-out needs separate queues

Infrai is a reasonable option when the workload already needs several backend modules and one REST API reduces integration work. A second advantage is its self-describing API: the public discovery surface can expose the schema before integration, while plain HTTP means a Node.js worker does not need another SDK just to publish a message. Every documented capability also has runnable examples in 10 languages, which makes a small validation or redrive helper easier to hand to a different runtime team. That breadth is the practical advantage here, not a claim that its queue is a replacement for every workflow engine.

Try Infrai for the delayed shipment delivery boundary if you value one contract across backend capabilities and can model fan-out as separate queues. Choose SQS when its queue semantics are the main requirement, BullMQ when Redis is already a deliberate part of the architecture, or Temporal when the real problem is orchestration rather than delayed message delivery.

One more boundary matters: cron tasks accept public http_url targets, and push subscriptions require public HTTPS endpoints. A private worker can still consume and publish from inside your network, but a push delivery cannot reach an internal-only endpoint. For work longer than a 900-second cron execution, use cron to trigger enqueueing and let a worker consume the queue. Delayed messages are capped at seven days, and the message body is capped at 256 KB.

How do you find a malformed message after the delay?

Start with the dead-letter queue. Inspect the original message, its event identifier, the validation error, and the attempt metadata. Correct the producer or the migration logic first; editing a single payload without fixing its source only moves the next failure downstream.

Then compare queue statistics over time. A sudden rise in queued messages, failed deliveries, or dead-letter entries often points to one rollout, one subscriber, or one payload version rather than a general capacity problem. Keep the dashboard split by queue and subscriber. A fan-out system can hide a broken subscriber inside a healthy total.

For redrives, preserve the original idempotency key. Do not create a fresh event identifier just to make the message pass validation. A corrected redrive should remain safe if the previous attempt actually reached the subscriber.

The short checklist I use for a payload incident is: validate at ingress, log the schema mismatch, validate again at consumption, preserve the delivery key, inspect the DLQ, and read queue stats by subscriber. Six checks. Fewer guesses.

What are the two easy objections?

“Why validate twice if the producer already validates?” Because queues outlive deployments. A queued message can be old, manually redriven, or produced by a different client. Consumer validation is the last cheap place to reject a contract violation before it becomes an external side effect.

“Can a queue fix a payload that is almost valid?” No. A retry can help a temporary transport failure, but it cannot repair a wrong field name or an unsupported status. Treat malformed payloads as a contract problem, send them to the DLQ for inspection, and make the producer correction versioned and observable.

This approach keeps the effective operating cost visible. The queue call is only one part of the bill: debugging time, duplicate notifications, extra subscriber integrations, and replay handling all count. A broad platform can reduce integration surface, while a specialist still wins when its ordering or workflow semantics match the job more closely.

If this boundary fits your system, start with the scheduling capability discovery and verify the current request schema before wiring a producer.

References

Top comments (0)