Deleting user data after 30 days: a webhook-triggered cleanup you can prove ran
A retention deadline is a promise about each record, not about a nightly run. An Express trigger, an idempotent delete worker and the evidence trail, in Node 22.
“Delete after 30 days” is a per-record deadline that happens to be discovered by a nightly job. That distinction changes the design: the scheduled task’s job is to find records that crossed their deadline and hand each one to something that will keep trying, and the thing that matters afterwards is being able to show which records were actually removed. Infrai’s queue is the handoff — a message per record, redelivery when a delete fails, and a dead-letter lane holding exactly the records whose deletion you still owe someone.
A nightly script that logs “cleanup complete” proves nothing at all.
The trigger fires more than once
Assume your scheduler double-fires. Vercel, GitHub Actions and Cloud Scheduler all offer at-least-once semantics on the trigger, and a retried HTTP request looks identical to a fresh one. So the webhook task has to be safe to run twice, which is easy if it only enumerates:
import express from "express";
import process from "node:process";
import { pool } from "./db.mjs";
const API = "https://api.infrai.cc";
const key = process.env.INFRAI_API_KEY;
const triggerSecret = process.env.CLEANUP_TRIGGER_SECRET;
if (!key || !triggerSecret) throw new Error("INFRAI_API_KEY and CLEANUP_TRIGGER_SECRET must be set");
const app = express();
app.post("/tasks/retention-sweep", express.json(), async (req, res) => {
if (req.get("x-trigger-secret") !== triggerSecret) return res.status(401).json({ error: "bad secret" });
const { rows } = await pool.query(
"SELECT id FROM user_uploads WHERE deleted_at IS NULL AND created_at < now() - interval '30 days' LIMIT 5000",
);
let queued = 0;
for (const row of rows) {
const r = await fetch(`${API}/v1/queue/publish`, {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({ queue: "retention-sweep", body: { upload_id: row.id } }),
});
const j = await r.json();
if (j.ok) queued += 1;
else console.error(`publish failed for ${row.id}: ${j.error.code}`);
}
res.json({ found: rows.length, queued });
});
app.listen(3000, () => console.log("retention trigger listening on :3000"));
Two runs an hour apart enqueue the same identifiers twice. That’s fine — the second delete is a no-op, because the worker is idempotent. Enumerating is cheap; deleting twice must be harmless.
The delete has to survive being repeated
Redelivery is the retry mechanism here, so every consumer runs at-least-once by construction. Make the state transition conditional and let the database arbitrate.
UPDATE user_uploads
SET deleted_at = now(), storage_key = NULL
WHERE id = $1
AND deleted_at IS NULL
RETURNING storage_key;
If RETURNING gives you a row, this attempt is the one that owns the deletion and should remove the object. If it gives you nothing, someone already did the work — ack and move on.
import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";
import { pool } from "./db.mjs";
const API = "https://api.infrai.cc";
const headers = {
Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
"Content-Type": "application/json",
};
async function q(path, payload) {
const r = await fetch(`${API}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
const j = await r.json();
if (!j.ok) throw new Error(`${path}: ${j.error.code} — ${j.error.message}`);
return j.data;
}
export async function worker() {
for (;;) {
const { items } = await q("/v1/queue/consume", { queue: "retention-sweep", max_messages: 10 });
if (!items.length) { await sleep(10_000); continue; }
for (const msg of items) {
const id = msg.payload.upload_id;
try {
const { rows } = await pool.query(
"UPDATE user_uploads SET deleted_at = now(), storage_key = NULL WHERE id = $1 AND deleted_at IS NULL RETURNING storage_key",
[id],
);
if (rows.length && rows[0].storage_key) await removeObject(rows[0].storage_key);
await q("/v1/queue/ack", { queue: "retention-sweep", receipt_handle: msg.message_id });
} catch (err) {
console.error(`upload ${id} delivery ${msg.delivery_count} failed: ${err.message}`);
}
}
}
}
async function removeObject(storageKey) {
console.log(`removing object ${storageKey}`);
}
Note what isn’t in that loop: no retry counter, no backoff table, no setTimeout chain. A throw means no ack, and the message returns by itself.
Delayed retry, without writing a scheduler
The delay is the queue’s visibility timeout. Consuming a message hides it for that long; if you never ack, it becomes available again and delivery_count goes up. Create the queue and you can read the numbers you’re actually working with:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"retention-sweep","type":"standard","dlq":"retention-sweep.dlq"}'
{
"ok": true,
"data": {
"name": "retention-sweep",
"type": "standard",
"visibility_timeout_default": 300,
"delivery_delay_seconds": 0,
"max_receive_count": 3,
"message_retention_days": 14,
"dlq_name": "retention-sweep.dlq"
}
}
The catch is that this gives you one retry interval, not a ladder. The documented publish body carries queue and body — there’s no per-message delay field — so if you need 1 minute, then 5, then 30, you build it by republishing onto queues with different visibility timeouts. For a retention sweep that’s rarely worth it: five minutes between attempts is fine when the deadline was thirty days.
Also worth knowing before you design the payload: message_retention_days is 14. A record parked in the dead-letter queue for a fortnight disappears, so the ledger below is the durable record, not the queue.
The evidence trail
Three things together answer “did the sweep run and did it finish”.
curl -sS "https://api.infrai.cc/v1/queue/stats/retention-sweep" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"queue": "retention-sweep",
"message_count": 0,
"available_count": 0,
"in_flight_count": 0,
"dlq_count": 2,
"oldest_message_age_seconds": 0
}
}
dlq_count: 2 is the number you alert on — two records passed their retention deadline and were not deleted, and somebody has to look at them. The second signal is your own deleted_at column, which is what an auditor will ask for. The third is the dead-letter queue itself, which you read as an ordinary queue by name:
curl -sS -X POST "https://api.infrai.cc/v1/queue/consume" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"retention-sweep.dlq","max_messages":10}'
We’d rather flag this than let you find it at 2am: on 2026-07-26 GET /v1/queue/dlq/list/{queue} came back with an empty list while the parent queue’s dlq_count was non-zero, and POST /v1/queue/dlq/redrive/{queue} returned an error. Consuming the .dlq queue directly worked every time.
What a nightly sweep costs
Only publishing is metered, at $0.00002 per message, verified 2026-07-26. A sweep that retires 3,000 uploads a night is 90,000 messages a month — $1.80. Consumes, acks, stats and the dead-letter reads are free routes, so a record that fails twice before succeeding costs exactly the same as one that works first time. New accounts get $2 in credit.
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.capabilities[] | select(.id | startswith("queue.")) | {id, price: .billing.price_usd, free: .billing.free}'
Prices in this category keep drifting down and campaigns run, so the live figure may be lower than the one above.
When you’d be better off elsewhere
If your retention rule is a single DELETE that finishes in 200ms, you don’t need a queue, a worker or this article — put it behind a cron entry and move on. If you already run Redis with a Node fleet, bullmq gives you repeatable jobs and the worker in one dependency, which is one fewer moving part than a scheduler plus a queue. And if the cleanup is a multi-step workflow with compensating actions, temporal is built for that shape and this isn’t.
Infrai’s queue fits when you want the retention job, the object deletion, the alert email and the error record to sit on one key and one bill.