Barua /Docs/Tasks

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 answersBarua does
2xxmarks the task done, keeps the body as its result
5xx, 408, 429, a timeout, or no connectionretries with backoff — about 10 s, 20 s, 40 s… up to an hour between tries
any other 4xxfails it for good — a handler that says “bad request” will say it again
out of attemptsthe 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

  1. 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_KEY in your app’s environment. (You can reveal it again from the same page.)
  2. Receive. Add one route to your app that accepts the delivery. The SDK’s handler does 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 });
    });
  3. 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" } });
  4. 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):

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

API reference

Base URL https://barua-tasks.kelvineffect.workers.dev. Every request carries Authorization: Bearer tk_… (your key). Bodies and responses are JSON.

CallWhat
POST /tasksCreate. { 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/:idOne task with runs: every attempt’s timing, HTTP status and error.
POST /tasks/:id/retryA dead, done or cancelled task back in the queue, now.
DELETE /tasks/:idCancel 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 /mcpThe 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

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

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.

Barua Tasks · Console sdk.mjs · sdk.d.ts Built in Dar es Salaam