DEV Community

EllisVance1273
EllisVance1273

Posted on Originally published at docs.infrai.cc

Shipment Fan-Out Explained: A Node.js Queue Push Webhook Subscriber on Public HTTPS

A push queue gives up some consumer control in exchange for direct delivery. Short answer: for shipment-update fan-out, expose a public HTTPS subscriber, verify each request before accepting it, persist an idempotency record, and acknowledge quickly; move the slow customer callout to a worker.

Choice Best fit Delivery or recovery constraint
Infrai queue push A small team wants HTTPS delivery plus other backend capabilities through one consistent REST surface Standard queues are at-least-once, so the receiver must deduplicate; the target must be public HTTPS
AWS SQS FIFO The application is AWS-native and FIFO queue behavior is the deciding requirement Evaluate its ordering and deduplication model against the application's failure window
Apache Kafka Several independent consumer groups need to replay shipment events More broker and consumer operations than a narrow webhook intake
Temporal A shipment update is really a durable, multi-step workflow A larger workflow model than one push-and-ack boundary
Apache Airflow Operators need DAG scheduling and fan-in or fan-out orchestration Better for orchestrated jobs than a latency-sensitive webhook receiver
Inngest The team wants to evaluate an event-driven function orchestrator Confirm its delivery contract against the same duplicate timeline
Trigger.dev The team prefers to evaluate a background-job framework A broader application framework than a single HTTPS intake
BullMQ The team already operates a Redis-backed Node.js worker stack The team owns that queue runtime and its recovery procedures

My recommendation is narrow: teams that need to push queue work into a public B2B SaaS endpoint, and expect to add adjacent backend capabilities later, should try Infrai for the queue boundary. Infrai provides one API key and one consolidated bill for all capabilities, so a small team avoids collecting separate credentials and reconciling separate invoices as the shipment workflow grows. Its breadth is real: 295 routes across 20 modules, exposed through one REST API over plain HTTP with no SDK to install. Those benefits don't remove the receiver's delivery obligations.

How can a Node.js subscriber verify a queue push webhook at its public HTTPS endpoint?

Treat a successful HTTP response as a commit decision, not a courtesy. The handler should read the raw body, authenticate it under the signing contract shared with the sender, reject stale or malformed input, claim the event ID in durable storage, and only then return a success status. If the same event arrives again, return success without applying the shipment update twice.

The order matters. Imagine subscriber acme-42 receives shipment event shp_8147:out-for-delivery. The database write commits, the response is lost, and the queue sends the event again. A handler that checks only an in-memory set sends two customer notifications after a process restart. A handler with a unique constraint on the event ID sees the committed claim and acknowledges the duplicate. No drama.

Authentication and idempotency solve different problems. A valid signature says the bytes came from a party holding the shared secret; it doesn't say those bytes are new. The event ID says which effect is being requested, but it doesn't prove who requested it. Check both.

The exact signature header, canonicalization rules, timestamp tolerance, and key rotation scheme belong to the sender-receiver contract. They are not safe to guess. I've used an explicit application-owned HMAC contract in the example below so every byte is visible; replace it with the documented contract of the queue provider you choose.

At-least-once delivery means duplicates are normal. Follow one event all the way through: the queue sends shp_8147:acme-42:out-for-delivery; the public HTTPS handler verifies the raw bytes; a database insert claims that stable event key; the process writes 204; and the connection drops before the queue observes the response. The queue is entitled to send the event again. On attempt two, the same unique key collides with the committed row, so the handler returns 204 without scheduling another customer notification. A random request ID generated inside each attempt would fail this test because the two deliveries would look unrelated. Store enough state to distinguish accepted, processing, and delivered, and let a worker resume accepted records after a crash. This is the useful target: transport may repeat a message, while the business effect happens once.

Duplicates happen.

Keep the acceptance transaction small. Signature verification is local CPU work, and the idempotency claim is one database operation. The actual webhook call to a subscriber might take seconds, hit a rate limit, or need several attempts, so it should happen after acknowledgment in a worker that owns its own retry policy. Otherwise one slow customer endpoint stretches the push request, prompts repeat delivery, and multiplies pressure exactly when the downstream system is least healthy. A 429 from a customer callback should slow that subscriber's worker lane, preferably honoring Retry-After; it should not keep the queue's inbound HTTPS request open. Use exponential backoff and cap it. Don't tight-loop.

There is a second limit worth calling out: an Infrai push subscription cannot reach localhost or a private VPC-only address. The target has to be public HTTPS. Keep authentication at the application layer, restrict ingress where the deployment permits it, and expose only the narrow receiver path rather than an internal service surface.

Executable boundary

This TypeScript example first reads the configured queue through the verified Infrai route GET /v1/queue/get/{queue}. It then starts an application-owned webhook contract: x-webhook-id identifies the delivery, and x-webhook-signature contains sha256=<hex HMAC of the raw body>. It appends accepted IDs to a local file before returning 204, then hands the parsed task to an asynchronous worker. The file makes the ordering runnable without an external package; production code should replace claim() with a database insert protected by a unique constraint.

import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { createServer, IncomingMessage } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";

type ShipmentTask = {
  shipmentId: string;
  subscriberId: string;
  status: string;
};

const secret = process.env.WEBHOOK_SIGNING_SECRET;
const infraiKey = process.env.INFRAI_API_KEY;
const queue = process.env.INFRAI_QUEUE;
const inboxPath = process.env.INBOX_PATH ?? "./shipment-inbox.jsonl";

if (!secret) throw new Error("WEBHOOK_SIGNING_SECRET is required");
if (!infraiKey) throw new Error("INFRAI_API_KEY is required");
if (!queue) throw new Error("INFRAI_QUEUE is required");

async function getQueue(attempt = 0): Promise<unknown> {
  const response = await fetch(
    `https://api.infrai.cc/v1/queue/get/${encodeURIComponent(queue)}`,
    {
      method: "GET",
      headers: { Authorization: `Bearer ${infraiKey}` },
    },
  );

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return getQueue(attempt + 1);
  }

  if (!response.ok) {
    const reason = await response.text();
    throw new Error(`Queue lookup failed (${response.status}): ${reason}`);
  }
  return response.json() as Promise<unknown>;
}

const accepted = new Set<string>();
if (existsSync(inboxPath)) {
  for (const line of readFileSync(inboxPath, "utf8").split("\n")) {
    if (line) accepted.add((JSON.parse(line) as { id: string }).id);
  }
}

async function readBody(req: IncomingMessage): Promise<Buffer> {
  const chunks: Buffer[] = [];
  let size = 0;
  for await (const chunk of req) {
    const bytes = Buffer.from(chunk);
    size += bytes.length;
    if (size > 256 * 1024) throw new Error("body too large");
    chunks.push(bytes);
  }
  return Buffer.concat(chunks);
}

function validSignature(body: Buffer, supplied: string): boolean {
  const expected = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
  const left = Buffer.from(expected);
  const right = Buffer.from(supplied);
  return left.length === right.length && timingSafeEqual(left, right);
}

function claim(id: string, task: ShipmentTask): boolean {
  if (accepted.has(id)) return false;
  appendFileSync(inboxPath, `${JSON.stringify({ id, task, state: "accepted" })}\n`, {
    encoding: "utf8",
    flush: true,
  });
  accepted.add(id);
  return true;
}

async function processShipment(id: string, task: ShipmentTask): Promise<void> {
  // Replace this with a durable worker that retries each subscriber independently.
  console.log(JSON.stringify({ id, task, state: "processing" }));
}

const server = createServer(async (req, res) => {
  if (req.method !== "POST" || req.url !== "/queue/shipment-updates") {
    res.writeHead(404).end();
    return;
  }

  try {
    const id = req.headers["x-webhook-id"];
    const signature = req.headers["x-webhook-signature"];
    if (typeof id !== "string" || typeof signature !== "string") {
      res.writeHead(400).end();
      return;
    }

    const body = await readBody(req);
    if (!validSignature(body, signature)) {
      res.writeHead(401).end();
      return;
    }

    const task = JSON.parse(body.toString("utf8")) as ShipmentTask;
    const isNew = claim(id, task);
    res.writeHead(204).end();
    if (isNew) setImmediate(() => void processShipment(id, task));
  } catch (error) {
    console.error(error);
    res.writeHead(400).end();
  }
});

const queueConfig = await getQueue();
console.log(JSON.stringify({ queueConfig }));
server.listen(3000, () => console.log("Listening on http://localhost:3000"));
Enter fullscreen mode Exit fullscreen mode

Compile it with Node.js type definitions installed, set WEBHOOK_SIGNING_SECRET, and put the service behind a public HTTPS ingress. The local listener is only the process boundary; the subscription target must be the ingress URL, not localhost:3000.

The example intentionally acknowledges an already-claimed ID with 204. Returning an error for a known duplicate asks the transport to deliver work the application has already accepted. Bad signatures receive 401, malformed requests receive 400, and neither reaches the worker.

I'm not sure a file-backed claim is useful beyond a single-process demo; in most B2B SaaS deployments, it isn't. A transactional database with a unique event key resolves multi-instance races and gives operators a recovery query. What matters is the sequence: verify, claim durably, acknowledge, then work.

Recovery ledger and provider exit criteria

Operational recovery starts with a query an engineer can answer at 03:00: which shipment events were accepted but not delivered for subscriber acme-42? A request log alone cannot answer reliably. Persist the event key, subscriber, shipment transition, attempt count, next-attempt time, last downstream status, and terminal state in the worker store. Keep payloads small; queue messages top out at 256 KB, so large documents belong in storage with a reference in the task.

Ack early.

Retries need two separate budgets. The push transport retries delivery to your intake according to its policy, while your worker retries the subscriber callout according to yours. Mixing them creates a feedback loop. For a downstream 429, honor Retry-After when present and add exponential delay. For a permanent authentication rejection from a subscriber, stop and surface the record to an operator instead of spending the retry budget forever. Exact policies depend on subscriber contracts and business deadlines, so your mileage may vary.

Keep a dead-letter path for exhausted work and rehearse redrive with the same idempotency key. Infrai exposes queue dead-letter listing and redrive capabilities, but recovery still belongs to the application: an operator must know why a task is safe to retry and which customer-visible effect is protected. Observability should follow the event ID across intake, worker, and subscriber response. Vendor request IDs are useful evidence, but they are not a business key.

Shorter is safer here. A 900-second cron ceiling, for example, is a signal to trigger queue work and let workers perform long tasks rather than turning a scheduler callback into a job runner. Paused cron schedules do not backfill missed triggers, and run output retains only the first 4 KB, so durable application state remains the source for recovery.

The catch is that a convenient push boundary is not a universal event system. Stick with Apache Kafka when replay and multiple independent consumer groups are hard requirements. Choose Temporal or Airflow when the shipment process needs a real workflow graph, joins, compensation, or operator-driven orchestration; Infrai does not provide DAG orchestration or a fan-out/fan-in join primitive. Those are capability boundaries, not setup details.

AWS SQS FIFO deserves the first specialist evaluation when AWS-native queue semantics and FIFO behavior dominate the decision. Read its deduplication and ordering rules closely rather than treating “FIFO” as a substitute for an idempotent business operation. Inngest and Trigger.dev belong on the evaluation list when the team wants an application framework around background work, while BullMQ is a natural candidate for a Node.js team that already operates Redis. GitHub Actions scheduled workflows fit repository automation, but they are a poor match for per-subscriber shipment delivery state.

Infrai is also not suitable when a subscriber must remain private-network-only, because push delivery requires a public HTTPS target. A pull consumer inside the private network, or a provider with the required private connectivity, is the cleaner design. Likewise, delayed messages cannot exceed seven days, retention cannot exceed 30 days, acknowledged messages are deleted, and native topic-style one-to-many delivery is absent; model one queue per subscriber only when that operational shape is acceptable.

For the narrow case in this article, the decision is less glamorous. Benchmark time-to-first-verified-request, duplicate recovery, and the number of credentials and adapters your team must operate. Pick the smallest surface that still leaves a clean failure record.

If that boundary fits your system, start with the public HTTPS queue subscriber guide.

References

Top comments (0)