Do you need a public HTTPS endpoint for cron and push queue delivery?
Which half of a scheduled daily-email backend really requires an internet-reachable URL, what that endpoint must do, and the polling design for teams that can't expose one.
Short answer: only if you choose push. A scheduled trigger has to reach something, and a push subscription delivers by calling you, so both need a URL that resolves from the public internet over TLS. Pulling doesn’t — an Infrai worker that calls POST /v1/queue/consume on a timer can sit inside a private network with no inbound rules at all, and for a daily email backend that’s often the easier build.
The confusion is worth untangling, because a daily report job has three moving parts and only one of them is opinionated about your network.
Which part needs the URL
| Part | Needs a public endpoint | Why |
|---|---|---|
| Scheduled trigger | Yes | The scheduler’s only way to reach your code is an HTTPS request |
| Push subscription on a queue | Yes | Delivery is an outbound call from Infrai to you |
| Polling consumer | No | Your process opens the connection outbound |
| Publishing work | No | Also outbound, from wherever your code runs |
So a design with a scheduler at the front always has one public URL somewhere. The question is whether it’s a thin one that just enqueues, or a fat one that does the whole job.
Make it thin. Always.
The daily email backend, in three moves
The schedule fires a small endpoint of yours. That endpoint doesn’t send anything — it enumerates who’s due and enqueues one message per recipient batch, then returns in a few hundred milliseconds. The workers then drain at whatever pace your email provider allows, which is the part that turns a 40,000-recipient send from a 15-minute request that times out into a job you can watch on a graph.
import express from "express";
import crypto from "node:crypto";
import process from "node:process";
const app = express();
app.use(express.json());
const KEY = process.env.INFRAI_API_KEY;
const TRIGGER_SECRET = process.env.CRON_TRIGGER_SECRET;
if (!KEY || !TRIGGER_SECRET) throw new Error("INFRAI_API_KEY and CRON_TRIGGER_SECRET are required");
function authentic(req) {
const presented = Buffer.from(String(req.get("X-Trigger-Token") ?? ""));
const expected = Buffer.from(TRIGGER_SECRET);
return presented.length === expected.length && crypto.timingSafeEqual(presented, expected);
}
app.post("/jobs/daily-digest", async (req, res) => {
if (!authentic(req)) return res.sendStatus(401);
const recipients = await dueForDigest(); // your database, your rules
const groups = [];
for (let i = 0; i < recipients.length; i += 25) groups.push(recipients.slice(i, i + 25));
let queued = 0;
for (const group of groups) {
const batch = {
queue: "daily-digest",
messages: group.map((user) => ({
payload: { user_id: user.id, email: user.email, digest_date: new Date().toISOString().slice(0, 10) },
idempotency_key: `digest:${user.id}:${new Date().toISOString().slice(0, 10)}`,
})),
};
const res2 = await fetch("https://api.infrai.cc/v1/queue/publish_batch", {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(batch),
});
const out = await res2.json();
if (!out.ok) return res.status(500).json({ error: out.error.code, queued });
queued += out.data.items.length;
}
res.json({ queued, groups: groups.length });
});
async function dueForDigest() {
return [{ id: "u_1", email: "ada@example.com" }];
}
app.listen(8080);
Note the endpoint returns a count, not a result. Whoever called it gets a fast 200 and the real work is now durable — if your process restarts thirty seconds later, the messages are still there.
One thing to get right before the first run: the queue must already exist for a batch publish. Send a single message to it first, since publishing to an unknown name is what brings a queue into being:
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":"daily-digest","body":{"warmup":true}}'
A batch against a name that has never been published to comes back 400 with not found, which is a confusing way to learn this. (The warmup call spells the message field body, an accepted alias; the reference and the batch example both use payload, and that’s the spelling to keep in code.)
If you do want push
A push subscription hands delivery to the platform: it calls your endpoint, retries on failure and dead-letters what never lands. Put the subscription in a file — these are the fields our subscription accepted, and the reference is the place to confirm the current list before you depend on any of them:
{
"url": "https://mail.example.com/consume/digest",
"secret": "whsec_rotate_me",
"max_retries": 5
}
curl -sS -X POST "https://api.infrai.cc/v1/queue/push_subscribe/daily-digest" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
--data @push.json
{
"ok": true,
"data": {
"subscription_id": "sub_TtUoCK2tsfjNN6aksNkDfrkJ",
"queue": "daily-digest",
"concurrency": 10,
"max_retries": 5,
"active": true,
"started_at": "2026-07-26T00:31:44.143679Z"
}
}
concurrency: 10 is the number that will surprise you. Ten deliveries can be in flight at once, so your receiver has to be safe under parallel calls for different messages — and if your email provider caps you below that, the receiver is where you enforce it, not the subscription.
Registration doesn’t test the URL. A typo, an expired certificate or a firewall rule shows up later as deliveries that never succeed, not as an error on the subscribe call, so verify the endpoint yourself right after you create the subscription.
What that endpoint has to do
Four requirements, all of them boring and all of them load-bearing. Serve valid TLS from a hostname that resolves publicly — a self-signed certificate or a tunnel that expired at the weekend fails silently. Answer within your caller’s timeout, which means acknowledging rather than working. Verify a shared secret on every request, because a URL that triggers your nightly send is a URL somebody else can call. And be idempotent on a key you control, since a delivery that succeeds after your 200 got lost in transit will simply arrive again.
That last one is not optional at scale.
The polling design, for locked-down networks
No inbound rules, no certificate, nothing exposed:
curl -sS -X POST "https://api.infrai.cc/v1/queue/consume" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"daily-digest","max_messages":10}'
Run that on a loop inside your VPC, ack each message after the email goes out, and the whole public-endpoint question disappears. Two trade-offs to accept: there’s no long-poll parameter, so an idle worker burns a request every few seconds and picks up work with a small delay, and you’re back to running a resident process — which is exactly what a push subscription was going to save you. If your consumer can’t be reached from the internet and you also can’t run a resident process, neither shape works and you’d be better off with a scheduled batch job that pulls once a day.
What the daily send costs
The queue side is nearly free: publishes are $0.00002 per message, verified 2026-07-26, and consume, ack, stats and the push subscription itself aren’t metered at all. Sending is the real line item — email is billed per message, around $0.000115 each on the cheapest vendor at the same date — so a 40,000-recipient digest is roughly $0.80 of queueing and about $4.60 of email. New accounts get $2 of credit to try both. These rates move downward more often than up; read them live rather than budgeting from this page:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '[.capabilities[] | select(.id == "queue.publish" or .id == "email.send") | {id, unit: .billing.unit, price: .billing.price_usd}]'
That’s the shape of the argument for keeping both halves on one account: the digest is queued, sent, and billed through a single credential, and per-tenant attribution is one query instead of a join across two vendors’ invoices.
Where other tools fit
QStash is push-only by design and does the scheduling too, so if a public endpoint is something you’re happy to run, it’s a tidy one-vendor answer for that narrow job. SQS with a Lambda trigger inverts the problem — AWS polls for you and you never expose anything — which is hard to beat if your code already runs there. Temporal is the right call when the digest is one step in a workflow that has to resume where it stopped.