Cloudflare Worker: send queue on a Durable Object
A Hono Worker that accepts SMS/LMS send requests and queues them in a Durable Object. The object sends them from its alarm with k-msg, retries the failures that can be retried, and cleans up after itself.
What this shows
Section titled “What this shows”POST /messagesvalidates{"to", "text"}and queues a job withcreateDurableObjectJobQueuefrom@k-msg/messaging/adapters/cloudflare. AnIdempotency-Keyheader is required: a retried request gets the job it already created instead of queueing the message twice.- The Durable Object’s alarm sends each job with
KMsgand a 10-second timeout. Errors thatErrorUtils.isRetryableaccepts are retried with exponential backoff capped at 5 minutes, never sooner than the provider’sretryAfterMs; afterSEND_MAX_ATTEMPTSattempts the job fails. - A job interrupted mid-send, for example by a deploy, is retried when its lease runs out instead of staying stuck.
- The alarm is set for the queue’s
nextDueAt(), so a retry runs when it is due, without polling. - Every 10 minutes,
cleanupTerminal({ olderThan })deletes jobs that finished over an hour ago and expired idempotency keys are swept, so storage does not grow without bound. - Every endpoint requires a bearer token, compared in constant time. The sender number comes from configuration, never from the request.
KMSG_PROVIDERselects the provider:mock(the default; it sends nothing),iwinv,solapioraligo.
Run it locally
Section titled “Run it locally”You need Bun (or npm) and openssl.
cd examples/cloudflare-worker-queue-dobun installecho "API_TOKEN=$(openssl rand -hex 32)" > .dev.varsbun run devwrangler dev serves the Worker on http://localhost:8787 with local Durable
Object storage. In a second terminal:
cd examples/cloudflare-worker-queue-doexport API_TOKEN=$(sed -n 's/^API_TOKEN=//p' .dev.vars)export KEY=$(openssl rand -hex 16)
curl -i http://localhost:8787/messages \ -H "Authorization: Bearer $API_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $KEY" \ -d '{"to":"010-1234-5678","text":"Your verification code is 482913"}'HTTP/1.1 202 AcceptedLocation: /messages/job_1790430000499_14869ec7Content-Type: application/json
{"jobId":"job_1790430000499_14869ec7"}The alarm fires right away and the mock provider accepts the message. The
wrangler dev terminal logs it, with the number masked:
{"level":"info","message":"message sent","jobId":"job_1790430000499_14869ec7","attempt":1,"to":"010****5678","providerId":"mock","providerMessageId":"mock-1790430000508-g9t3isjax"}Run the same curl again: the response has the same jobId and an
Idempotent-Replayed: true header, and nothing new is queued. Sending a
different body with the same key returns 422 IDEMPOTENCY_KEY_REUSED.
Look up the job, using the jobId you got:
curl http://localhost:8787/messages/job_1790430000499_14869ec7 \ -H "Authorization: Bearer $API_TOKEN"{"jobId":"job_1790430000499_14869ec7","status":"completed","failedAttempts":0,"maxAttempts":5,"createdAt":"2026-09-26T13:40:00.499Z","nextAttemptAt":null,"completedAt":"2026-09-26T13:40:00.509Z","failedAt":null,"lastError":null,"providerId":"mock","providerMessageId":"mock-1790430000508-g9t3isjax"}See the queue’s counters, and run a pass without waiting for the alarm:
curl http://localhost:8787/queue/stats -H "Authorization: Bearer $API_TOKEN"curl -X POST http://localhost:8787/queue/drain -H "Authorization: Bearer $API_TOKEN"{"dueJobs":0,"openJobs":0,"totals":{"enqueued":1,"replayed":1,"sent":1,"retried":0,"failed":0,"interrupted":0,"removedJobs":0,"expiredKeys":0},"nextAlarmAt":"2026-09-26T13:50:00.499Z","lastCleanupAt":null,"nextCleanupAt":"2026-09-26T13:50:00.499Z"}{"processed":0,"sent":0,"retried":0,"failed":0}Use a real provider
Section titled “Use a real provider”Add the provider, its keys, and a sender number registered with that provider
to .dev.vars, then restart bun run dev. For example:
API_TOKEN=...KMSG_PROVIDER=solapiKMSG_SENDER_NUMBER=0212345678SOLAPI_API_KEY=...SOLAPI_API_SECRET=...KMSG_PROVIDER |
Required secrets | Notes |
|---|---|---|
mock (default) |
none | Sends nothing. |
iwinv |
IWINV_API_KEY, IWINV_SMS_API_KEY, IWINV_SMS_AUTH_KEY |
IWINVProvider needs the AlimTalk key even for SMS. |
solapi |
SOLAPI_API_KEY, SOLAPI_API_SECRET |
|
aligo |
ALIGO_API_KEY, ALIGO_USER_ID |
ALIGO_TEST_MODE=true validates requests without sending them. |
Every provider except mock also needs KMSG_SENDER_NUMBER, digits only
(hyphens are removed). Korean carriers only deliver from sender numbers
registered with the provider in advance; any other number is rejected. From
here on, messages are really sent, so test with your own phone number.
.dev.vars.example lists every variable. Non-secret settings
(KMSG_PROVIDER, KMSG_SENDER_NUMBER, SEND_MAX_ATTEMPTS) have defaults in
the vars of wrangler.jsonc; .dev.vars overrides them locally.
Endpoints
Section titled “Endpoints”Every endpoint needs Authorization: Bearer <API_TOKEN>. Errors look like
{"error":{"code":"INVALID_INPUT","message":"..."}}.
| Endpoint | What it does | Success response |
|---|---|---|
POST /messages |
Queues a message. Needs an Idempotency-Key header. |
202 {"jobId":"..."} with a Location header; a replay also has Idempotent-Replayed: true. |
GET /messages/:jobId |
Returns the job’s status. | 200 with the job (below). |
GET /queue/stats |
Counters, due and open jobs, next alarm and cleanup times. | 200 |
POST /queue/drain |
Runs a pass now (up to 25 jobs) instead of waiting for the alarm. | 200 {"processed","sent","retried","failed"} |
POST /messages takes {"to": "01012345678", "text": "..."} and nothing
else: to is a Korean mobile number (hyphens and spaces are allowed), text
is at most 2,000 bytes with non-ASCII characters counted as 2, and a text over
90 bytes is sent as LMS. A from field is rejected. The body may be at most
16 KiB. The Idempotency-Key is 1 to 255 visible ASCII characters, such as a
UUID; reuse it whenever you retry the same message.
A job has status pending, processing, completed or failed, plus
failedAttempts, maxAttempts, nextAttemptAt (while pending),
lastError: the KMsgErrorCode of the last failed attempt, or
LEASE_EXPIRED when the object stopped mid-send, and, once the provider
accepted the message, providerId and providerMessageId. It never includes
the recipient or the text.
| Status | Code | When |
|---|---|---|
| 400 | INVALID_JSON |
The body is not JSON. |
| 400 | INVALID_INPUT |
A field is missing, invalid or unexpected. |
| 400 | IDEMPOTENCY_KEY_REQUIRED |
POST /messages has no Idempotency-Key. |
| 401 | UNAUTHORIZED |
The bearer token is missing or wrong. |
| 404 | NOT_FOUND |
Unknown route, or no such job (finished jobs are deleted by the cleanup). |
| 413 | PAYLOAD_TOO_LARGE |
The body is over 16 KiB. |
| 422 | IDEMPOTENCY_KEY_REUSED |
The key was already used for a different message. |
| 500 | CONFIGURATION_ERROR |
A variable or secret is missing or invalid; the Worker logs name it. |
| 503 | QUEUE_UNAVAILABLE |
The Durable Object is overloaded or restarting; retry with the same key. |
| 500 | INTERNAL_ERROR |
Anything else. |
Provider errors never reach the client: sending happens in the background,
so they show up as the job’s lastError and in the logs.
How the queue sends
Section titled “How the queue sends”- The alarm handles up to 25 due jobs per pass, one at a time, and then sets
itself for
nextDueAt(): the next retry or lease expiry, or at once when a full pass left jobs due. - Each job being sent is leased for 5 minutes. If the object stops mid-send,
the job is due again when the lease runs out, and that attempt counts as
failed (
LEASE_EXPIRED). - Retryable errors (by default
NETWORK_ERROR,NETWORK_TIMEOUT,NETWORK_SERVICE_UNAVAILABLE,RATE_LIMIT_EXCEEDED,PROVIDER_ERRORandUNKNOWN_ERROR) wait 2, 4, 8 seconds and so on, capped at 5 minutes, with jitter; if the provider sentretryAfterMs, at least that long (up to one hour). Other errors, such asINVALID_REQUEST,AUTHENTICATION_FAILEDorINSUFFICIENT_BALANCE, fail the job at once. - Every 10 minutes, starting 10 minutes after the first job, jobs that
finished over an hour ago are deleted with
cleanupTerminal({ olderThan })and idempotency keys older than 24 hours are removed. A finished job can be looked up for at least an hour.
The Durable Object logs message sent, send failed; retry scheduled,
send failed; giving up, job was interrupted mid-send and
queue cleaned up as JSON lines, with phone numbers masked.
Deploy
Section titled “Deploy”bunx wrangler loginbunx wrangler secret put API_TOKEN # paste the output of: openssl rand -hex 32bunx wrangler secret put SOLAPI_API_KEY # the secrets of your providerbunx wrangler secret put SOLAPI_API_SECRETSet KMSG_PROVIDER and KMSG_SENDER_NUMBER in the vars of
wrangler.jsonc, then deploy:
bun run deployThe first deploy applies the v1 migration, which creates the SQLite-backed
SendQueue Durable Object class.
Production checklist
Section titled “Production checklist”- Generate
API_TOKENwithopenssl rand -hex 32, keep it in a secret, and give it only to the services that send messages. - Register
KMSG_SENDER_NUMBERwith your provider before going live. - Delivery is at least once. A send that times out or is interrupted is
retried even though the provider may already have accepted it, so a
recipient can occasionally get a message twice. Lower
SEND_MAX_ATTEMPTSif a duplicate is worse than a missed message. - One Durable Object sends one message at a time, and the library queue reads
every stored job, in one storage listing, each time it takes one. For more
volume, spread messages over several named queues (
getByName) and keep finished jobs for a shorter time. - Finished jobs are deleted an hour after they finish. Keep Workers Logs (enabled by
observabilityinwrangler.jsonc), or add delivery tracking as incloudflare-worker-hyperdrive, if you need a lasting record. - The SOLAPI SDK ignores the abort signal, so the 10-second timeout does not bound SOLAPI calls.
- IWINV and Aligo can restrict their APIs to registered server IP addresses, and Workers do not send from fixed IPs. Check your account’s IP settings.
- Alert on
send failed; giving upandjob was interrupted mid-send.