Resolve the URL, then fetch it
download_url is a path such as /v1/messages/{id}/media. Resolve it against your API base URL and request it with an API key that carries messages:read. The provider's own file reference is never exposed; this endpoint is the only way in, and it is authenticated and scoped to your workspace.
curl -L https://www.chatrail.dev/v1/messages/019bf4c3-0000-7000-8000-000000000001/media \
-H "Authorization: Bearer $CHATRAIL_API_KEY" \
-o reply.jpgWhat comes back
The response streams the file. Its Content-Type is the type ChatRail stored, never one echoed from the transport, and it always carries Content-Disposition: attachment and X-Content-Type-Options: nosniff so customer-supplied media can never render inline on a ChatRail origin. A Content-Length is set when the size is known.
import { createWriteStream } from "node:fs";
import { Readable } from "node:stream";
const res = await fetch(new URL(media.download_url, "https://www.chatrail.dev"), {
headers: { Authorization: `Bearer ${process.env.CHATRAIL_API_KEY}` },
});
if (res.status === 200) {
await new Promise((resolve, reject) => {
Readable.fromWeb(res.body).pipe(createWriteStream("reply.bin"))
.on("finish", resolve).on("error", reject);
});
}When there is nothing to fetch
A download_url of null on the webhook means the transport reported a file it will not serve. The message still arrived; there is simply nothing to download. An outbound attachment never carries a download_url either — that URL is the one you supplied when you sent it, and you already have it.
| Field | Type | Notes |
|---|---|---|
200 | — | The file, streamed as an attachment. |
404 | — | No attachment on the message, or the transport reported a file it does not serve, or it no longer holds it. |
503 | — | This deployment cannot retrieve media from the transport. |
Why it is proxied, not redirected
ChatRail streams the bytes rather than handing you the transport's URL. That URL names an internal host, is usually unreachable from your network, and carries no authentication of its own. Proxying keeps the boundary the product sets everywhere else: ChatRail stores the reference, never the file, and only the transport's own origin is ever fetched.