Barua Tasks
Durable background work for any application. Enqueue a task with one call; Barua keeps it, delivers it to your app when it is due, retries when your app fails, runs schedules, and keeps every attempt where you can read it and put it back.
Overview
Applications constantly need work to happen after the request ends — send the welcome email, resize the upload, retry the webhook, run the nightly report. Doing that reliably normally means running a queue, workers, a scheduler and a retry system of your own. Tasks is that part, as a service, for an app on any host.
The shape is simple. Your app enqueues a task. Barua delivers it by sending an HTTP POST to a URL your app answers on. What your app answers decides what happens next:
| Your handler answers | Barua does |
|---|---|
2xx | marks the task done, keeps the body as its result |
5xx, 408, 429, a timeout, or no connection | retries with backoff — about 10 s, 20 s, 40 s… up to an hour between tries |
any other 4xx | fails it for good — a handler that says “bad request” will say it again |
| out of attempts | the task goes to the dead-letter with its last error, for you to read and retry |
Delivery is at least once. A task is never lost, but a retry on your side can create a second one — the idempotency key is how you make that harmless.
Quickstart
- Switch it on. In the console, open a database → Add-ons → Tasks → Enable. Give it the URL your app will receive tasks on. You get a key — save it as
BARUA_TASKS_KEYin your app’s environment. (You can reveal it again from the same page.) - Receive. Add one route to your app that accepts the delivery. The SDK’s
handlerdoes the signature check and the answer for you:import { handler } from "barua-tasks"; export const POST = handler(process.env.BARUA_TASKS_KEY, async (task) => { if (task.name === "send-welcome-email") return sendEmail(task.payload.userId); throw Object.assign(new Error(`no handler for ${task.name}`), { status: 400 }); }); - Send. Anywhere in your app:
import { createTasks } from "barua-tasks"; const tasks = createTasks({ key: process.env.BARUA_TASKS_KEY }); await tasks.create({ name: "send-welcome-email", payload: { userId: "user_123" } }); - Watch it. The Tasks page in the console shows what is waiting, done, and what needs attention — with the reason, and a Retry button.
Install the SDK
The SDK is one file with no dependencies, written against web standards (fetch and WebCrypto), so the same module runs in Node 18+, Cloudflare Workers, Next.js, Deno, Bun and browsers. Get it any of three ways:
npm
npm install barua-tasks
one file, vendored into your project
curl -o lib/barua-tasks.mjs https://barua-tasks.kelvineffect.workers.dev/sdk.mjs
# types, if you want them:
curl -o lib/barua-tasks.d.ts https://barua-tasks.kelvineffect.workers.dev/sdk.d.ts
by URL (Deno, browsers)
import { createTasks, handler } from "https://barua-tasks.kelvineffect.workers.dev/sdk.mjs";
Everything below writes from "barua-tasks"; with the vendored file it is from "./lib/barua-tasks.mjs". Nothing else changes.
Receive tasks — your handler
Barua delivers a task as a POST with a JSON body:
{
"id": "tsk_km2k2p2fnvfu7wcg",
"name": "send-welcome-email",
"payload": { "userId": "user_123" },
"attempt": 1,
"max_attempts": 5,
"scheduled_at": "2026-10-14T08:00:00.000Z"
}
and these headers: x-barua-task-id, x-barua-task-name, x-barua-attempt, and x-barua-signature — an HMAC-SHA256 of the raw body, hex-encoded, keyed with your project key. Verify it; nothing else can forge a delivery. You have 30 seconds to answer.
The SDK’s handler(key, fn) turns your function into a (Request) => Response handler that verifies, parses, and answers. The contract of fn(task):
- return a value, or nothing →
200, task done; what you return is kept as its result - throw →
500, task retried with backoff - throw an error with
status: 400→400, task fails for good (an unknown name, an unknown user — things a retry will not fix)
Cloudflare Workers
import { handler } from "barua-tasks";
async function run(task) {
if (task.name === "send-welcome-email") return sendEmail(task.payload.userId);
throw Object.assign(new Error(`no handler for ${task.name}`), { status: 400 });
}
export default {
fetch(request, env) {
return handler(env.BARUA_TASKS_KEY, run)(request);
},
};
Put the key in a secret: wrangler secret put BARUA_TASKS_KEY. Your Worker’s URL is the handler URL you gave the console.
Next.js (App Router)
app/api/tasks/route.ts — a route handler already has the (Request) => Response shape:
import { handler } from "barua-tasks";
export const POST = handler(process.env.BARUA_TASKS_KEY!, async (task) => {
switch (task.name) {
case "send-welcome-email": return sendEmail(task.payload.userId);
case "generate-report": return generateReport(task.payload);
default:
throw Object.assign(new Error(`no handler for ${task.name}`), { status: 400 });
}
});
Handler URL: https://your-app.example/api/tasks. Keep this route out of any middleware that rewrites the body — the signature is over the raw bytes.
Node · Express
Express parses bodies before you see them, so read the raw body on this route and use verify directly:
import express from "express";
import { verify } from "barua-tasks";
const app = express();
const KEY = process.env.BARUA_TASKS_KEY;
app.post("/tasks", express.raw({ type: "*/*" }), async (req, res) => {
const body = req.body.toString("utf8");
const signature = req.get("x-barua-signature") ?? "";
const ok = await verify({ key: KEY, body, signature });
if (!ok) return res.status(401).json({ error: "bad signature" });
const task = JSON.parse(body);
try {
const out = await run(task); // return = done, throw = retry
res.json(out ?? { ok: true });
} catch (e) {
res.status(e.status ?? 500).json({ error: e.message }); // status 400 = fail for good
}
});
app.listen(3000);
Hono
import { Hono } from "hono";
import { handler } from "barua-tasks";
const app = new Hono();
app.post("/tasks", (c) => handler(c.env.BARUA_TASKS_KEY, run)(c.req.raw));
export default app;
Deno · Bun
// Deno — import by URL, no install
import { handler } from "https://barua-tasks.kelvineffect.workers.dev/sdk.mjs";
Deno.serve(handler(Deno.env.get("BARUA_TASKS_KEY"), run));
// Bun
import { handler } from "barua-tasks";
Bun.serve({ fetch: handler(process.env.BARUA_TASKS_KEY, run) });
Any language
There is nothing JavaScript-specific about Tasks: receiving is a signed POST, sending is an HTTP API (reference below). Verify the signature with HMAC-SHA256 over the raw body and compare it to the header in constant time. In Python:
import hmac, hashlib, json, os
from flask import Flask, request
app = Flask(__name__)
KEY = os.environ["BARUA_TASKS_KEY"].encode()
@app.post("/tasks")
def tasks():
body = request.get_data() # raw bytes
want = hmac.new(KEY, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(want, request.headers.get("x-barua-signature", "")):
return {"error": "bad signature"}, 401
task = json.loads(body)
if task["name"] == "send-welcome-email":
send_email(task["payload"]["userId"])
return {"ok": True} # 200 → done
return {"error": "no handler"}, 400 # 400 → fail for good; raise/500 → retry
To enqueue from Python, or anything with an HTTP client:
import requests
requests.post("https://barua-tasks.kelvineffect.workers.dev/tasks",
headers={"Authorization": f"Bearer {KEY}"},
json={"name": "send-welcome-email", "payload": {"userId": "user_123"}})
Send tasks — enqueue
import { createTasks } from "barua-tasks";
const tasks = createTasks({ key: process.env.BARUA_TASKS_KEY });
const t = await tasks.create({
name: "send-welcome-email", // required — your handler switches on it
payload: { userId: "user_123" }, // any JSON, up to 64 KB
});
t.id; // "tsk_…"
t.status; // "pending"
Reading back, inspecting, and acting:
await tasks.get(t.id); // the task, with every delivery attempt
await tasks.list({ status: "dead" });
await tasks.failed(); // the dead-letter, newest first
await tasks.retry(t.id); // back in the queue, to run now
await tasks.cancel(t.id); // a task still waiting
await tasks.stats(); // counts by status, next due
Delays and schedules
// in an hour
await tasks.create({ name: "remind", payload: { id: 7 }, delay: 3600 });
// at a moment (ISO 8601, up to 30 days out)
await tasks.create({ name: "publish", payload: { post: 12 }, run_at: "2026-11-01T09:00:00Z" });
// on a schedule: five-field cron, UTC — runs on every match, forever
await tasks.create({ name: "nightly-report", cron: "0 2 * * *" });
await tasks.create({ name: "sweep", cron: "*/15 * * * *" });
Cron understands *, numbers, ranges (1-5), steps (*/15), lists (1,15); Sunday is 0 or 7; when both day-of-month and weekday are set, either matching counts. A cron task never dies: a failed occurrence is recorded with its error and the next one is booked. Cancel it to stop the schedule.
Retries and failures
When your handler fails in a way that might pass next time — a 5xx, a timeout, a dropped connection, a 429 — Barua retries with exponential backoff and jitter: roughly 10 s, then 20 s, 40 s, 80 s… capped at an hour, so a thousand failing tasks never line up and hit you in the same second. After max_attempts (default 5, up to 25) the task is dead: it keeps its payload and last error, every attempt stays on record, and it waits for a retry.
When your handler says the task itself is wrong — any 4xx except 408/429 — Barua does not retry. It will not pass next time either.
const t = await tasks.get("tsk_km2k2p2fnvfu7wcg");
t.status; // "dead"
t.last_error; // 'HTTP 500: {"error":"Cannot read properties of undefined (reading \'email\')"}'
t.runs; // [{ attempt: 1, status: "retry", http_status: 500, ms: 197, error: … }, { attempt: 2, status: "dead", … }]
// fix the handler, then:
await tasks.retry(t.id); // attempts reset, runs now
Idempotency
Two sides to it. Your handler may see the same task twice (at-least-once delivery) — make it safe to run again: check before you charge, upsert instead of insert. Your producer may retry a create after a network blip — pass an idempotency_key and the second call returns the first task instead of making another:
const t = await tasks.create({
name: "charge-order",
payload: { orderId: "ord_9" },
idempotency_key: "charge-ord_9", // up to 200 characters, unique per project
});
t.deduplicated; // true on the second call with the same key
For AI assistants
Tasks is built so the assistant that wrote your handler can debug it. The same queue is exposed as MCP tools — failed_tasks, get_task, retry_task, list_tasks, create_task, cancel_task, queue_stats — so Claude can read why a task failed, with the real error and payload, fix the code, and put the task back, without a person reading a dashboard in between.
In Claude Code:
claude mcp add --transport http barua-tasks \
https://barua-tasks.kelvineffect.workers.dev/mcp \
--header "Authorization: Bearer tk_…"
Then: “Check my failed tasks and fix the handler.” The assistant calls failed_tasks, sees Cannot read properties of undefined (reading 'email') on send-welcome-email with the payload that caused it, patches the code, and calls retry_task. That loop is the point.
Security
- Verify every delivery. The
x-barua-signatureheader is the HMAC-SHA256 of the raw body under your key.handlerandverifydo this; if you roll your own, compare in constant time and hash the raw bytes, not a re-serialised object. - The key is a secret. It both authorises your app to enqueue and signs what Barua sends you. Keep it in your environment, never in client-side code. The console only shows it when you ask.
- Rotate by turning Tasks off and on again on the database: a new key, and the old one stops working.
- Payloads are stored and shown in the console. Put an id in the payload and look the sensitive thing up in your handler, rather than putting the thing itself in the task.
- Deliveries come from
user-agent: barua-tasks/1; the signature is the only thing to trust.
API reference
Base URL https://barua-tasks.kelvineffect.workers.dev. Every request carries Authorization: Bearer tk_… (your key). Bodies and responses are JSON.
| Call | What |
|---|---|
POST /tasks | Create. { name, payload?, delay?, run_at?, cron?, max_attempts?, idempotency_key?, url? } → 201 the task; 200 with deduplicated: true on an idempotency hit. |
GET /tasks?status=&name=&limit= | List, newest first. status: pending · running · done · dead · cancelled. limit up to 200. |
GET /tasks/:id | One task with runs: every attempt’s timing, HTTP status and error. |
POST /tasks/:id/retry | A dead, done or cancelled task back in the queue, now. |
DELETE /tasks/:id | Cancel a task still waiting. |
GET /stats | { handler_url, tasks: { pending, running, done, dead, cancelled }, next_due } |
POST /handler | { handler_url } — change where tasks are delivered. |
POST /mcp | The queue as MCP tools (JSON-RPC). |
a task
{
"id": "tsk_…", "name": "send-welcome-email", "payload": { … }, "url": null,
"status": "pending" | "running" | "done" | "dead" | "cancelled",
"attempts": 0, "max_attempts": 5,
"next_run_at": "…", "cron": null, "idempotency_key": null,
"created_at": "…", "updated_at": "…", "finished_at": null,
"last_error": null, "result": null
}
field rules
name— letters, digits,_ . : -; up to 100 charactersdelay— seconds;run_at— ISO 8601; either up to 30 days outcron— five fields, UTC; takes precedence overdelay/run_atmax_attempts— 1 to 25, default 5url— deliver this task somewhere other than the project’s handler
Errors are { "error": "…" } with 400 (invalid), 401 (no or wrong key), 404, or 409 (e.g. retrying a task that is already pending).
SDK reference
import { createTasks, verify, handler } from "barua-tasks";
const tasks = createTasks({ key, url? }); // url defaults to the Barua Tasks service
tasks.create(task) // → Task see "a task" above
tasks.get(id) // → Task & { runs }
tasks.list({ status?, name?, limit? }) // → Task[]
tasks.failed(limit = 20) // → Task[] the dead-letter
tasks.retry(id) // → Task
tasks.cancel(id) // → Task
tasks.stats() // → Stats
tasks.setHandler(handler_url) // → { handler_url }
verify({ key, body, signature }) // → Promise<boolean> body is the raw request text
handler(key, fn) // → (Request) => Promise<Response>
// fn(task): return → done · throw → retry · throw { status: 400 } → fail for good
Every method throws an Error with a status property on a non-2xx answer. TypeScript types ship with the package (index.d.ts).
Limits
- Payload up to 64 KB; a handler’s result is kept up to 4 KB, an error up to 2 KB.
max_attempts1–25, default 5.- A task can be scheduled at most 30 days out.
- 10 deliveries in flight per project at a time; 30 s for your handler to answer.
- Finished tasks and their attempts are kept for 7 days.
- Delivery is at least once.
Troubleshooting
Tasks die with HTTP 401: bad signature. Your handler is verifying with a different key than the one that signed the delivery — usually an old key after Tasks was turned off and on, or a key from another database. Check BARUA_TASKS_KEY against the one the console shows. If you verify by hand, make sure you hash the raw body: a framework that parses and re-serialises JSON changes the bytes.
no handler for <name>. Your handler threw with status: 400 for a name it does not know, so the task failed for good. Add the case and retry the task.
Tasks time out. You have 30 seconds. Do the work and answer; for something longer, have the handler enqueue the next step as its own task and return.
Tasks stay pending. They are scheduled for later (next_run_at), or Barua cannot reach your handler URL at all — check GET /stats for next_due and the task’s last_error.
A task ran twice. At-least-once delivery, usually after your handler answered slowly and a retry overlapped. Make the handler idempotent (see above).
409: task is already pending on retry. It is already queued; nothing to do.