Cron hit the 900-second wall and your worker has no public URL

Reminder jobs fail two ways at once: the scheduler times out at 900 seconds, and the box doing the work isn't reachable. A pull queue removes both problems.

Split the tick from the work. A scheduler that fires an HTTPS request should get an answer in under a second — its job is to say “now”, not to wait around while you send 40,000 reminders — and the process that actually sends them should pull its work rather than wait to be called. Infrai’s queue routes do both halves: one metered publish per reminder, then a worker that dials out and never listens on a port.

That second half is the part people miss. A pull consumer has no inbound surface at all, so “public HTTPS endpoint not reachable” stops being a problem you have to solve with ngrok, a load balancer, or a firewall exception ticket.

Two failures, one error message

The 900-second ceiling and the unreachable endpoint produce the same user-visible symptom — reminders didn’t go out — so they get diagnosed together and fixed neither.

What you seeWhat actually happenedThe fix
Scheduler logs a timeout at 900syour trigger handler did the sending inlinereturn immediately, publish instead
ECONNREFUSED / TLS handshake failure at the schedulerthe worker box has no routable HTTPS endpointpull with POST /v1/queue/consume, don’t be pushed to
Reminders sent twicethe timeout killed the request but not the work, then the scheduler retriedmake the send idempotent on your own key
Job “succeeded”, nothing arrivedhandler returned 200 before the async sends finishedpublish, then let the worker report

Only the first row is a timeout problem. The rest are architecture.

The tick can be one curl

You don’t need an HTTP endpoint of your own to start a run. A crontab line that publishes a single “sweep” message is a legitimate scheduler, and it finishes in about 60ms.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"reminders-due","body":{"kind":"sweep","window_minutes":15}}'
{
  "ok": true,
  "data": {
    "message_id": "qmsg_vDU0GhHMH9FuvwL7NKpCKS1O",
    "queue": "reminders-due",
    "payload": { "kind": "sweep", "window_minutes": 15 },
    "status": "available",
    "delivery_count": 0,
    "published_at": "2026-07-26T00:33:41.568872Z"
  }
}

Two details worth knowing. The queue is created on first publish, so there’s no chicken-and-egg step before this works. And the response echoes your body back as payload — the field is accepted under both names, which trips people up the first time they parse it.

Put that in crontab and the timeout question disappears, because nothing is waiting:

*/15 * * * * curl -sS --max-time 10 -X POST "https://api.infrai.cc/v1/queue/publish" -H "Authorization: Bearer ${INFRAI_API_KEY}" -H "Content-Type: application/json" -d '{"queue":"reminders-due","body":{"kind":"sweep","window_minutes":15}}' >> /var/log/reminder-tick.log 2>&1

The worker watches its own clock

Here’s the piece the winning pages on this query mostly skip: a worker that runs under a supervisor with its own kill timer needs a wall-clock budget, and it needs to hand back whatever it didn’t finish. Unacked messages return automatically once the visibility timeout expires (300 seconds by default on the queues we tested), so stopping early is safe — it costs you a redelivery, not a lost reminder.

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

const BASE = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const headers = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

async function post(path, payload) {
  const res = await fetch(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
  const out = await res.json();
  if (!res.ok || out.ok === false) {
    const err = out.error ?? { code: res.status, message: res.statusText };
    throw new Error(`${path} -> ${err.code}: ${err.message}`);
  }
  return out.data;
}

// Stop 60s before the supervisor's own limit so the last ack always lands.
const DEADLINE = Date.now() + 840_000;

async function deliver(payload) {
  // Replace with your SMS/email/push call; it must be safe to run twice.
  console.log("sending", payload);
}

while (Date.now() < DEADLINE) {
  const { items } = await post("/v1/queue/consume", { queue: "reminders-due", max_messages: 10 });
  if (items.length === 0) {
    await sleep(5000);
    continue;
  }
  for (const msg of items) {
    if (Date.now() >= DEADLINE) break; // leave the rest unacked; they come back
    try {
      await deliver(msg.payload);
      await post("/v1/queue/ack", { queue: "reminders-due", receipt_handle: msg.message_id });
    } catch (e) {
      console.error(`delivery ${msg.message_id} failed on attempt ${msg.delivery_count}: ${e.message}`);
    }
  }
}
console.log("budget spent; exiting cleanly");

Note what isn’t there: no server, no TLS certificate, no port. The worker makes outbound calls only. Run it on a laptop behind NAT and it still drains the queue.

Proving it drained

curl -sS "https://api.infrai.cc/v1/queue/stats/reminders-due" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "queue": "reminders-due",
    "message_count": 0,
    "available_count": 0,
    "in_flight_count": 0,
    "delayed_count": 0,
    "dlq_count": 1,
    "oldest_message_age_seconds": 0
  }
}

oldest_message_age_seconds is the number to alert on. If it climbs past your reminder’s tolerance, you have a worker problem, not a scheduler problem — and a non-zero dlq_count means something has already given up after three deliveries.

The bill for all this

Publishing is the only metered route here: $0.00002 per message, verified 2026-07-26. Consuming, acking and reading stats are free and rate-limited rather than billed, which is what makes redelivery an acceptable retry strategy — a reminder that bounces three times costs the same as one that lands first time. New accounts get $2 of free credit, which is roughly 99,000 publishes.

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

Run that instead of trusting this paragraph. Rates on this platform move down and discount campaigns run, so what you read today may well be lower than what’s printed here.

When you’d be better off elsewhere

If your team already runs Redis and a Node fleet, bullmq gives you delayed jobs, repeatable jobs and a dashboard that a REST queue simply doesn’t have — and its per-job delay is real, which brings us to the honest limitation: setting delivery_delay_seconds on a queue had no effect in our testing, and messages became available immediately. Schedule the tick, don’t schedule the message. For workflows with human approval steps or multi-day state machines, temporal is a different and better category of tool.

The other trade-off is throughput shape: max_messages tops out at 10 per consume call, so a million-message backlog wants several workers rather than a bigger batch size. If your reminders need a public endpoint pushed to you rather than pulled, that’s the push-subscribe path, covered separately at the push-subscribe consumer guide.

And the reason to keep this on one key rather than three: the reminder send, the error you capture when it fails, and the per-tenant cost attribution your finance lead asks for in October all live on the same account.

References

Browse more queue developer guides