All posts

A QStash alternative built for indie hackers

QStash by Upstash is a genuinely good product. It's an HTTP-based messaging layer that lets serverless apps publish messages, delay them, schedule them on cron, fan out to multiple endpoints, and retry on failure — all over a simple REST API with no connection to manage. If you need a real message queue that works from the edge, it's an easy recommendation.

But a lot of the time, you don't need a message queue. You need one specific thing: "call this URL, once, at this future second, signed, and retry if it fails." When that's the whole job, a purpose-built webhook scheduler is simpler to reason about and cheaper to run. This is an honest look at when SendItWhenever is the better fit, when QStash is, and how to migrate the common case.

Want the side-by-side feature grid instead of prose? See SendItWhenever vs QStash.

Where QStash shines

Be clear about what you'd be giving up, because it's real:

  • It's a true queue. Topics / URL groups let one publish fan out to many subscribers. If you need pub/sub, that's first-class.
  • General-purpose messaging. Callbacks, message TTLs, flow control, batching — QStash is a building block for whole event-driven systems, not just scheduled calls.
  • Part of the Upstash ecosystem. If you already use Upstash Redis or Vector, it's one more service in a familiar console and bill.

If any of that describes your need, stay on QStash — the rest of this post won't change your mind, and it shouldn't.

Where indie hackers feel friction

The friction shows up when your actual need is narrow but the tool is broad:

  • Per-request pricing on a narrow job. QStash bills around messages/requests. For a steady trickle of "schedule a reminder" calls that's fine; for spiky or high-volume one-off scheduling, a flat fee is easier to predict. SendItWhenever is a flat $19/month on the entry tier — no per-message math.
  • A queue's surface area for a scheduler's task. Topics, callbacks, flow-control, and message semantics are power you pay for in concepts even when all you wanted was schedule(url, time, payload).
  • Webhook ergonomics aren't the center of gravity. Signing, verification helpers, a delivery log you can scan, and a Dead Letter Queue you can replay are the product in a dedicated scheduler, rather than features layered onto a general queue.

A purpose-built scheduler, end to end

SendItWhenever does one job and puts the webhook lifecycle front and center:

  • Second-level precision with Early Firing — it measures round-trip latency to your endpoint and fires slightly early to land on time.
  • HMAC signature on every delivery via the X-SendIt-Signature header, with verifySignature() helpers in the SDK.
  • Encryption at rest — payloads are stored with AES-256-GCM and only decrypted in memory at the firing second.
  • Retries + DLQ — up to five attempts with exponential backoff, then a Dead Letter Queue you can inspect in the dashboard.
  • A unified delivery log so you can see exactly what fired, when, and with what response.

Migrating the common case

If you're using QStash purely to delay or schedule a call to your own endpoint, the move is essentially one function. Here's the before and after.

QStash — publish with a delay:

import { Client } from "@upstash/qstash";

const qstash = new Client({ token: process.env.QSTASH_TOKEN! });

await qstash.publishJSON({
  url: "https://api.myapp.com/trial-ending",
  body: { userId },
  delay: 3 * 24 * 60 * 60, // seconds from now
});

SendItWhenever — schedule for an absolute second:

import { SendIt } from "@sendithq/sdk";

const sendit = new SendIt(process.env.SENDIT_API_KEY!);

await sendit.schedule({
  url: "https://api.myapp.com/trial-ending",
  payload: { userId },
  fireAt: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
  idempotencyKey: `trial:${userId}`,
});

The receiver side is the same shape too — read the raw body, verify the signature, then act:

const sendit = new SendIt(process.env.SENDIT_API_KEY!, {
  signingSecret: process.env.SENDIT_SIGNING_SECRET!,
});

// in your route handler
const rawBody = await req.text();
const ok = sendit.verifySignature({
  headers: Object.fromEntries(req.headers),
  rawBody,
});
if (!ok) return new Response("bad signature", { status: 401 });

How to choose

  • Need pub/sub, fan-out, callbacks, or a general message bus? Use QStash.
  • Need to fire one signed webhook at a future second, with retries and a log, for a flat price? That's exactly what SendItWhenever is for.

Pick the tool whose shape matches your problem. If your problem is "schedule a webhook," you shouldn't have to adopt a whole queue to do it.

Get started