Send an onboarding email 24 hours after signup, no scheduler of your own

Yes, the email service can hold the message: scheduled_at on the send, a 24-hour queue delay, or a one-shot cron. What each costs you in precision and cancellability.

Yes — hand the timing to the send itself. POST /v1/email/send on Infrai accepts a scheduled_at timestamp, and a message posted at signup with scheduled_at set to signup plus 24 hours is accepted immediately and held until then. No worker, no queue, no cron entry, no row in your database whose only job is to remember. One call at signup and you’re done.

That’s the short answer, and for a lot of products it’s the right one. The rest of this page is about the two things it costs you — precision and the ability to change your mind — and what to use instead when either matters.

The one-call version

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "dana@example.com",
    "subject": "Day 1: three things to try in Kettle",
    "html": "<p>Hi Dana — yesterday you signed up. Here are three things worth ten minutes.</p>",
    "scheduled_at": "2026-07-27T05:09:21Z",
    "idempotency_key": "onboarding_day1:usr_4821"
  }'

The acceptance echoes the hold back to you, along with the sender it used:

{
  "ok": true,
  "data": {
    "message_id": "msg_h0Dw835DBSKZ4I1YaWBeNzCZ",
    "mode": "default_vendor",
    "from_used": "noreply+a1f9@send.infrai.cc",
    "accepted_recipients": ["dana@example.com"],
    "suppressed_recipients": [],
    "scheduled_at": "2026-07-27T05:09:21Z"
  }
}

Wire it into the signup handler and the whole feature is a function:

// schedule-day1.mjs — Node 22 ESM, no dependencies.
import process from "node:process";

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

/** Books the day-1 email at signup time. Returns the message id to store. */
export async function scheduleDayOne({ userId, email, firstName, signupAt = new Date() }) {
  const fireAt = new Date(signupAt.getTime() + 24 * 60 * 60 * 1000);

  const res = await fetch(`${API}/v1/email/send`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify({
      to: email,
      subject: "Day 1: three things to try in Kettle",
      html: `<p>Hi ${firstName} — yesterday you signed up. Here are three things worth ten minutes.</p>`,
      scheduled_at: fireAt.toISOString().replace(/\.\d{3}Z$/, "Z"),
      idempotency_key: `onboarding_day1:${userId}`,
    }),
    signal: AbortSignal.timeout(10_000),
  });

  const payload = await res.json().catch(() => ({}));
  if (!res.ok || payload.ok === false) {
    const e = payload.error ?? {};
    throw new Error(`day-1 scheduling failed: ${e.code ?? res.status} ${e.message ?? ""}`);
  }
  return { messageId: payload.data.message_id, scheduledAt: payload.data.scheduled_at };
}

const booked = await scheduleDayOne({
  userId: "usr_4821",
  email: "dana@example.com",
  firstName: "Dana",
  signupAt: new Date("2026-07-26T05:09:21Z"),
});
console.log(booked);

idempotency_key is what makes that safe to call from a signup handler. Send the same key twice and you get the same message_id back, billed once — so a retried transaction, a replayed webhook or a redeployed worker doesn’t produce two day-1 emails. Derive it from something stable like the user id, never from a timestamp.

The catch: you can’t take it back

Once the message is accepted it belongs to the sending path. There’s no cancel route for a scheduled email, and the account archive doesn’t model the hold — GET /v1/email/get/{id} reports state: "sent" within seconds of the call, hours before the mail actually goes anywhere.

curl -sS "https://api.infrai.cc/v1/email/get/msg_h0Dw835DBSKZ4I1YaWBeNzCZ" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

So the scheduled_at echo in the send response is your receipt — store it. And ask the hard question before you choose this path: if the user deletes their account, unsubscribes, or churns four hours after signing up, do you still want that email to arrive? If the answer is no, you need a decision point at fire time, and that means one of the two patterns below.

Precision, meanwhile, is fine here but not literal. “Exactly 24 hours” in mail terms means the message enters the delivery path then; queueing at the receiving mailbox adds its own seconds or minutes, and no provider controls that.

Pattern two: a 24-hour queue delay

If you want the decision deferred but not the infrastructure, publish a delayed message instead. delay_seconds accepts up to 604800 — seven days — so a day is comfortably inside it:

curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "queue": "kb-onboarding-day1",
    "payload": {"user_id": "usr_4821", "email": "dana@example.com", "template_id": "tmpl_QxMgF5zmgsJcslhG4RmKV19m"},
    "delay_seconds": 86400,
    "idempotency_key": "onboarding_day1:usr_4821"
  }'

Worth flagging, because it looks like a bug: the publish response comes back with "status": "available" even though the message is invisible for the next 24 hours. The queue statistics tell the truth.

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

delayed_count: 1, available_count: 0 — that’s a message correctly parked. Alert on delayed_count never falling and you’ll notice a stuck consumer before your users do.

The trade-off is that something has to drain it. That “something” can be tiny — a POST /v1/queue/consume on a one-minute schedule — but if the premise is no worker at all, this pattern doesn’t meet it. What it buys is the cancellation you lost above: the consumer re-reads the user record, sees the account is gone, acks the message and sends nothing.

Pattern three: a one-shot cron per signup

POST /v1/cron/create takes either a recurring cron_expr or an absolute run_at, and the one-shot form fires exactly once against a URL you own:

curl -sS -X POST "https://api.infrai.cc/v1/cron/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "onboarding-day1-usr_4821",
    "run_at": "2026-07-27T05:07:22Z",
    "task": "https://kettle.example/internal/onboarding/day1",
    "payload": {"user_id": "usr_4821"},
    "timezone": "UTC"
  }'
{
  "ok": true,
  "data": {
    "job_id": "cron_XwK6jSuXQblj28UNz4qCue4z",
    "name": "onboarding-day1-usr_4821",
    "cron_expr": "7 5 27 7 *",
    "task_type": "http_url",
    "max_runs": 1,
    "status": "active",
    "next_run_at": "2026-07-27T05:07:22+00:00"
  }
}

Look at cron_expr. A run_at is compiled into a minute-granular expression with max_runs: 1, so the seconds you asked for are not honoured — expect the call within the right minute, not the right second. For an onboarding nudge that’s irrelevant; for anything where a minute matters, it’s a limitation you want to know about before you design around it. Jobs are listable and deletable (GET /v1/cron/list, DELETE /v1/cron/delete/{id}), so a user who churns can have their pending nudge removed, which the scheduled send can’t offer.

Creating one job per signup is fine at thousands of users and silly at millions — at that scale the hourly sweeper is the better shape: one recurring cron, a query for accounts that crossed the 24-hour mark, one batch send.

Choosing between them

ApproachWho owns the timingCancellablePrecisionNeeds a consumer or endpoint
scheduled_at on the sendthe email servicenodelivery-path minutesno
Queue delay_secondsthe queueyes, at fire timeseconds, plus poll intervala consumer
One-shot cron per userthe scheduleryes, delete the jobto the minutean HTTP endpoint
Hourly sweeper cronyour queryyesup to an hour latean HTTP endpoint
Loops or Brevo automationthe marketing toolyes, in their UIminutesno

If the message is unconditional — a welcome sequence you’d send regardless — take row one and stop building. If it’s conditional on the user still existing, still being unverified, still not having done the thing you’re nudging about, take row two or four and make the decision at fire time.

Costs, and what this surface won’t do

Only two things here are metered: the send at $0.000115 per email and a queue publish at $0.00002 per call, both verified 2026-07-26 against $2 of free credit on a new account. Cron jobs, queue stats, consume, ack and every message read are free and rate-limited. Read the live figures:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; d=json.load(sys.stdin); print([(c['id'], c['billing'].get('price_usd')) for c in d['capabilities'] if c['id'] in ('email.send','queue.publish','cron.create')])"

Those rates drift downward as vendor discounts land, so today’s lookup may be lower. One structural note that outlives any price: scheduling a send costs the same as sending it now — there’s no premium for the hold.

What this isn’t is a lifecycle marketing platform. There’s no campaign builder, no branching on opens and clicks, no preference centre and no editor a marketer can use without a deploy — multi-step nurture sequences belong in Loops or Brevo, and you should buy one the moment someone outside engineering owns the copy. SendGrid’s send_at and Resend’s own scheduled_at cover the same single-message hold if you’re already on one of those. Infrai’s version wins when the day-1 email sits next to the queue, the cron, the template and the error tracking on one key — and when you’d rather not add a marketing tool to send one message.

References

Browse more email developer guides