Cloudflare R2 to HLS
Objects land in an R2 bucket. R2 publishes the event to a Queue, a consumer Worker posts it to an ingest rule, and the rule turns it into an HLS ladder.
R2 has no HTTPS notification target of its own — event notifications go to a Queue — so a Worker makes the call. That is an advantage rather than a detour: the Worker can sign the request, so the only thing stored in Cloudflare is a Worker secret, and every delivery proves itself with a timestamped signature rather than a static bearer token.
Before you start
- An R2 bucket, and
wranglerlogged into the same account. - A Transcodely origin pointing at that bucket with
readpermission — provider R2, with your account ID and an API token, as in Storage setup. - A preset for the ladder you want.
1. Create the rule
curl -X POST https://api.transcodely.com/transcodely.v1.IngestRuleService/Create
-H "Authorization: Bearer {{API_KEY}}"
-H "Content-Type: application/json"
-d '{
"origin_id": "ori_a1b2c3d4e5f6",
"name": "R2 uploads",
"filters": {
"prefix": "incoming/",
"suffixes": [".mp4", ".mov"],
"min_bytes": 1024
},
"action": {
"outputs": [{ "preset": "pst_x9y8z7w6v5" }],
"managed": true,
"output_path_template": "{input_dir}/{input_name}/{job_id}"
}
}'Keep the secret from the response — it is shown once — and note rule.endpoint_url.
The output_path_template above mirrors the source layout into the outputs. {input_dir} and {input_name} come from the object key the event names; an incoming incoming/2026/holiday.mp4 writes its ladder under incoming/2026/holiday/…. Provider-supplied keys are sanitized first, so a crafted key cannot write outside your prefix.
2. Create the queue and the notification
npx wrangler queues create transcodely-r2-events
npx wrangler queues create transcodely-r2-events-dlq
npx wrangler r2 bucket notification create YOUR_BUCKET
--event-type object-create
--queue transcodely-r2-events
--prefix incoming/The --prefix here and the rule’s prefix filter do the same job at two layers. Setting both is not redundant: the bucket-side prefix keeps unrelated objects out of your queue entirely, and the rule-side filter is what decides whether an event that did arrive becomes a job.
3. Deploy the consumer Worker
wrangler.toml:
name = "transcodely-ingest"
main = "src/index.ts"
compatibility_date = "2026-04-01"
# No fetch handler, so leaving the route unset keeps the Worker unreachable
# over HTTP. It only ever runs as a queue consumer.
workers_dev = false
[[queues.consumers]]
queue = "transcodely-r2-events"
max_batch_size = 10
max_batch_timeout = 5
max_retries = 5
dead_letter_queue = "transcodely-r2-events-dlq"src/index.ts — the templates repo ships this as cloudflare-r2/worker/src/index.ts, in TypeScript and with the R2 event types
declared. The logic is the same:
const ENDPOINT = "https://api.transcodely.com/ingest/ing_a1b2c3d4e5f6";
// What the endpoint documents about itself:
// 202 recorded, or already recorded -> stop
// 400 not a shape it understands -> stop
// 401 bad or missing authentication -> stop
// 404 no such rule, or it was deleted -> stop
// 413 over the body cap -> stop
// 429 too fast -> RETRY
// 5xx it failed to record the event -> RETRY
//
// Every 4xx except 429 is a statement about THIS request, and the
// same bytes get the same answer. Cycling those until the queue
// gives up only buries the real failure under a pile of retries.
export function shouldRetry(status) {
if (status >= 200 && status < 300) { return false; }
if (status === 429) { return true; }
if (status >= 400 && status < 500) { return false; }
return true;
}
export default {
async queue(batch, env) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
enc.encode(env.INGEST_SECRET),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
for (const message of batch.messages) {
const e = message.body;
const body = JSON.stringify({
bucket: e.bucket,
key: e.object.key,
etag: e.object.eTag,
size: e.object.size,
content_type: e.object.httpMetadata?.contentType ?? ''
});
const t = Math.floor(Date.now() / 1000);
const mac = await crypto.subtle.sign(
'HMAC', key, enc.encode(`${t}.${body}`)
);
const v1 = [...new Uint8Array(mac)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Transcodely-Signature': `t=${t},v1=${v1}`
},
body
});
if (shouldRetry(res.status)) {
message.retry();
} else {
if (!res.ok) {
// The status, never the key and never the body.
console.error(
`transcodely ingest refused a delivery: ${res.status}`
);
}
message.ack();
}
}
}
};Then set the secret and deploy:
npx wrangler secret put INGEST_SECRET
npx wrangler deployThe templates repo also keeps the endpoint in a secret (INGEST_URL) rather
than inline, so rotating a rule is one wrangler secret put instead of a
deploy.
The signature is HMAC-SHA256(secret, "<timestamp>.<body>"), hex-encoded, in a t=…,v1=… header — the same scheme that signs our outbound webhooks. The timestamp must be within five minutes, which is what stops a captured request being replayed later. It is the strongest of the three auth forms, and the one to prefer wherever you write the request yourself.
Retrying is safe. An object is identified by (rule, bucket, key, etag), so a redelivered message is answered 202 with the id of the event that already won, and no second job is created.
4. Verify
Drop a file into the bucket under your prefix, then read the rule’s log:
curl -X POST https://api.transcodely.com/transcodely.v1.IngestRuleService/ListEvents
-H "Authorization: Bearer {{API_KEY}}"
-H "Content-Type: application/json"
-d '{ "rule_id": "ing_a1b2c3d4e5f6" }'{
"events": [
{
"id": "sev_a1b2c3d4e5f6g7",
"rule_id": "ing_a1b2c3d4e5f6",
"bucket": "customer-uploads",
"object_key": "incoming/2026/holiday.mp4",
"etag": "d41d8cd98f00b204e9800998ecf8427e",
"size_bytes": 734003200,
"source": "generic",
"status": "created",
"job_id": "job_a1b2c3d4e5f6"
}
]
}source: generic is expected — the Worker posts the generic shape, which is what the shape detector sees.
Rotating the secret
curl -X POST https://api.transcodely.com/transcodely.v1.IngestRuleService/Update
-H "Authorization: Bearer {{API_KEY}}"
-H "Content-Type: application/json"
-d '{ "id": "ing_a1b2c3d4e5f6", "rotate_secret": true }'The new secret is on that response, once. The previous one keeps verifying for 24 hours, so wrangler secret put INGEST_SECRET and a deploy inside that window drop no
events.
When nothing arrives
| What you see | What it means |
|---|---|
| Queue backs up, messages retried | The Worker is getting a 429 or a 5xx. Tail it with npx wrangler tail. |
| Messages land in the dead-letter queue | Retries ran out on something transient, or the Worker itself is throwing. |
401 from the endpoint | Wrong secret, or the timestamp is outside the five-minute window. Check the Worker’s clock handling and rotate if in doubt. |
| No events, empty queue | The bucket notification is not matching. Check --prefix against the keys actually being written. |
status: skipped, reason: bucket_mismatch | The event names a different bucket than the rule’s origin. |
status: failed | Job creation was refused; the reason is the API error code and it is not retried. Clear the cause, then replay the event. |
Next
- Ingest rules — filters, deduplication, every status and reason
- Supabase Storage upload to HLS — the same pattern with a static header
- Output paths — the rest of the template variables