Push subscription or pull consumer? Where the webhook rate limit has to live

Subscribing an HTTPS endpoint to a queue hands pacing to the sender. Pulling keeps it. A Node example of both, with ack, nack and what reaches the dead-letter lane.

Choose by asking who should be allowed to say “slow down”. Subscribe an HTTPS endpoint with POST /v1/queue/push_subscribe/{queue} and Infrai drives the pace — your service absorbs whatever arrives, and the only brake you have is returning a non-2xx and hoping the retry ladder is kind. Pull with POST /v1/queue/consume and the brake is yours: you ask for at most ten messages when you’re ready for them.

For a fan-out that hits customer endpoints with wildly different tolerances, that difference decides the architecture. Push is fewer moving parts. Pull is the one with a throttle.

Push subscriptionPull consumer
Needs a public HTTPS endpointyesno, it dials out
Who controls concurrencythe subscription (10 by default)your loop
Backpressure signalHTTP status code onlyjust stop calling consume
Ackimplicit in a 2xx responseexplicit POST /v1/queue/ack
Retry budgetmax_retries on the subscriptionmax_receive_count on the queue
Good forsteady, low-volume, always-on servicesrate-limited destinations, batch drains, laptops behind NAT

What subscribing actually returns

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/queue/push_subscribe/webhook-fanout" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"webhook-fanout","url":"https://hooks.example.com/infrai"}'
{
  "ok": true,
  "data": {
    "subscription_id": "sub_pTL1ILtQ35kLQefCSH02qg5Q",
    "queue": "webhook-fanout",
    "concurrency": 10,
    "max_retries": 3,
    "active": true,
    "started_at": "2026-07-26T00:36:01.238472Z"
  }
}

Note the body carries queue as well as the path segment; omit either and you get INVALID_ARGUMENT: queue.push_subscribe needs 'queue' + 'url'. Two defaults are load-bearing. concurrency: 10 means ten in-flight requests against your endpoint, not one. max_retries: 3 is how many attempts a message gets before it’s someone else’s problem.

And a caveat worth knowing before you turn this on: the queue namespace has no unsubscribe route, so treat an active subscription as a thing you create deliberately and point at an endpoint you control.

The receiver answers first, works later

The single most common way a subscribed endpoint falls over is doing the work inside the request. Acknowledge, then process.

import { createServer } from "node:http";
import process from "node:process";

const SECRET = process.env.WEBHOOK_SHARED_SECRET;
if (!SECRET) throw new Error("WEBHOOK_SHARED_SECRET is not set");

const pending = [];

async function drain() {
  while (pending.length) {
    const job = pending.shift();
    try {
      console.log("processing", job.webhook_id ?? job);
    } catch (e) {
      console.error("processing failed:", e.message);
    }
  }
}
setInterval(drain, 100).unref();

createServer((req, res) => {
  if (req.method !== "POST" || req.headers["x-shared-secret"] !== SECRET) {
    res.writeHead(401).end();
    return;
  }
  let raw = "";
  req.on("data", (c) => { raw += c; });
  req.on("end", () => {
    if (raw.length > 262_144) { res.writeHead(413).end(); return; }
    try {
      pending.push(JSON.parse(raw));
      res.writeHead(202, { "Content-Type": "application/json" }).end('{"accepted":true}');
    } catch {
      res.writeHead(400).end('{"error":"invalid json"}');
    }
  });
}).listen(8080, () => console.log("listening on :8080"));

Returning 202 in a millisecond and buffering in memory is fine here precisely because the queue still holds the message until the delivery is judged successful. If you crash with a full buffer you lose nothing that matters.

Drop one event in to see the whole path move:

curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"webhook-fanout","body":{"url":"https://hooks.example.com/infrai","event":{"type":"order.shipped","order_id":91422}}}'

The pull version, with a real throttle

Ten concurrent deliveries you didn’t ask for is the problem push can’t solve. Here the loop decides.

import process from "node:process";
import { setTimeout as wait } from "node:timers/promises";

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const QUEUE = "webhook-fanout";
const RPS = 4;

const headers = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
const infrai = async (route, payload) => {
  const res = await fetch(`https://api.infrai.cc${route}`, { method: "POST", headers, body: JSON.stringify(payload) });
  const json = await res.json();
  if (json.ok !== true) throw new Error(`${route}: ${json.error.code}`);
  return json.data;
};

async function deliver(payload) {
  const res = await fetch(payload.url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload.event),
  });
  return res.ok;
}

for (;;) {
  const { items } = await infrai("/v1/queue/consume", { queue: QUEUE, max_messages: 10 });
  if (!items.length) { await wait(4000); continue; }

  for (const message of items) {
    const started = Date.now();
    let ok = false;
    try {
      ok = await deliver(message.payload);
    } catch (e) {
      console.error(`delivery error: ${e.message}`);
    }
    if (ok) {
      await infrai("/v1/queue/ack", { queue: QUEUE, receipt_handle: message.message_id });
    } else {
      await infrai("/v1/queue/nack", { queue: QUEUE, message_id: message.message_id });
    }
    const spend = Date.now() - started;
    if (spend < 1000 / RPS) await wait(1000 / RPS - spend);
  }
}

Four requests a second, held there whatever the queue depth is.

Ack, nack, and the third strike

queue.ack takes the message identifier and removes it. queue.nack puts it straight back rather than waiting out the visibility window — useful when you know the failure was transient, dangerous in a tight loop, because an immediate nack still burns one of the three deliveries.

curl -sS -X POST "https://api.infrai.cc/v1/queue/nack" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"webhook-fanout","message_id":"qmsg_qyGNpDHp1JhZRUZsaB1DWF8T"}'

The queue’s max_receive_count is 3 by default. On the fourth attempt the message is gone from the main lane and sitting in webhook-fanout.dlq, which is an ordinary queue you can consume from:

curl -sS -X POST "https://api.infrai.cc/v1/queue/consume" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"webhook-fanout.dlq","max_messages":10}'

Worth flagging from our testing on 2026-07-26: GET /v1/queue/dlq/list/{queue} returned an empty items array on a queue whose dlq_count was non-zero, so consume the .dlq queue by name instead of relying on that helper.

The cost of each mode

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.capabilities[] | select(.id | startswith("queue.")) | {id, free: .billing.free, price_usd: .billing.price_usd}'

Both modes bill identically, because only publishing is metered — $0.00002 per message, verified 2026-07-26. Subscribing, consuming, acking, nacking and draining the dead-letter queue are free and rate-limited rather than charged, so choosing pull doesn’t cost you anything for the extra round trips. New accounts get $2 of free credit. Rates drift down over time, so run the call rather than quoting this line.

When another broker wins

If you need genuine per-consumer prefetch and priority routing, rabbitmq gives you knobs this REST surface doesn’t expose, and running it is a solved problem. If your requirement is “POST this URL, retry with backoff, rate limit to N per second” and you want no consumer at all, qstash does exactly that and you’d be better off there than assembling it yourself.

The boundary to be honest about: there’s no server-side rate limiter here — the pacing in that consumer is yours to write and yours to get wrong. What you get instead is that the same key handles the storage, the notification email and the error capture around this delivery path, so the second question doesn’t need a second vendor. If the puzzle you’re solving is a scheduler timing out rather than a rate limit, the cron timeout and worker-reachability guide covers that side.

References

Browse more queue developer guides