Queue Workers
The SDK queue delivers each message at least once. You add messages to a queue (see SDK → Queue) and a worker processes them: an HTTP handler in your app that the platform POSTs each message to. Each app only sees its own queues and its own workers.
Worker settings
A worker is defined for a queue by the settings below. The framework client has no call to register a worker: HTTP workers are enabled for your app by the platform.
| Field | Required | Notes |
|---|---|---|
queue |
yes | Name of the queue to process |
handler_url |
yes | The handler URL, on your app's domain |
concurrency |
no | Maximum simultaneous deliveries (default 1, minimum 1) |
visibility_timeout_secs |
no | How long a delivery may take before it is retried (default 30) |
A queue has one worker: defining a new one replaces the previous one. Workers can also be listed and removed.
A message is dead-lettered after 5 deliveries without an ack.
handler_url must be an http or https URL with a domain name (no IP address, no
credentials, no # fragment), under one of the callback URLs the platform has allowed for
your app. A malformed URL is refused with 400; if HTTP workers are not enabled for the app,
or the URL is not allowed, the refusal is a 403. If the authorization is withdrawn later,
deliveries are paused without using up attempts.
Example settings:
{
"queue": "emails",
"handler_url": "https://<app>.inklura.fr/api/queue/emails",
"concurrency": 4,
"visibility_timeout_secs": 60
}
The handler contract
On each delivery, the platform POSTs this JSON envelope to your handler_url:
{ "id": "<message-id>", "queue": "emails", "payload": { }, "attempts": 0 }
payload carries the value passed to push. With sdk.queue.push it arrives as a JSON
string: decode it with JSON.parse, as in the example below. attempts counts previous deliveries: 0 on the
first one. Messages in a queue are picked up in arrival order, but with a concurrency
above 1 several deliveries run in parallel.
Your response status decides what happens next:
| Response status | Outcome |
|---|---|
2xx |
Ack: the message is done and removed |
408, 429, 5xx |
Retry: the message is redelivered later (up to 5 deliveries) |
| any other status | Dead-letter: the message moves to the dead-letter queue, no retry |
| no response (unreachable, timed out) | Retry once visibility_timeout_secs has passed |
On a retry status, a Retry-After header (a whole number of seconds) sets the delay before
redelivery; without it the delay is 30 seconds. Either way the delay is capped at the
visibility timeout. Your handler has visibility_timeout_secs to answer (at least 30
seconds, at most 900): set it above your handler's longest run time, otherwise a slow but
successful delivery is retried.
// src/app/api/queue/emails/route.ts
export async function POST(req: Request) {
const { id, payload, attempts } = await req.json();
const data = typeof payload === "string" ? JSON.parse(payload) : payload;
try {
await sendEmail(data);
return new Response("ok"); // 2xx → ack
} catch (e) {
if (isTransient(e)) {
return new Response("retry", { // 5xx → retry in 60 s
status: 503,
headers: { "Retry-After": "60" },
});
}
return new Response("bad payload", { status: 400 }); // 4xx → dead-letter
}
}
Delivery is at least once. The same message can arrive more than once (for example if
your 2xx response was lost, or after a visibility timeout). Make your handlers
idempotent by keying side effects on the message id.
Tracking a job: add a record in KV
The queue stores no result. Once a message is acknowledged it is gone: nothing tells you
afterwards whether the job succeeded or what it produced. If you need a status or a result,
write your own record, ideally in KV under a job:<id> key.
import { createSdk } from "@bext-stack/framework/sdk";
const sdk = createSdk("<app-id>");
// when enqueuing (push returns the message id)
const id = await sdk.queue.push("emails", payload);
await sdk.kv.set(`job:${id}`, { status: "queued", createdAt: Date.now() });
// in the handler, id comes from the envelope
await sdk.kv.set(`job:${id}`, { status: "running" });
// …work…
await sdk.kv.set(`job:${id}`, { status: "done", result });
kv.set replaces the whole value: carry over the fields you want to keep on each write.
If you need built-in status and progress, use tasks instead.
Dead letters
Messages that use up their 5 deliveries, or for which the handler returned a non-retryable status, land in the dead-letter queue. They stay there, with their payload, until you retry or purge them:
| Operation | Purpose |
|---|---|
| List | Inspect dead-lettered messages (50 by default, at most 500) with their payload and attempt count |
| Retry | Put the messages back in the queue with the attempt count reset to zero (a whole queue, or one message by id) |
| Purge | Delete the messages permanently (a whole queue, or one message by id) |
Related
- SDK → Queue: enqueuing, delays, the client
- SDK → Tasks: long jobs with status and progress
- Scheduled Jobs: recurring work