Signed Download URLs#
EnConvert does not put your converted file in the response body by default. It uploads the file to object storage and returns a signed URL: an ordinary HTTPS link that carries its own authorization in the query string and stops working 15 minutes after it is issued.
Why the output is a link#
Two reasons, both practical.
The response stays small. A conversion answer is a few hundred bytes of JSON whatever the output weighs, so your client parses one predictable shape whether the result is a 4 KB Markdown file or a 140 MB ZIP of a whole site. It also means a batch status response can carry 400 results without carrying 400 files.
The bytes come from storage, not from the API. Downloads are served by the storage layer directly, so a slow client pulling a large PDF does not hold an API worker open or sit behind the same proxy timeouts that bound a conversion request. The link needs no X-API-Key header, which is what makes it safe to hand to a browser, a queue consumer, or a curl in a shell script.
What the URL looks like#
It is a standard AWS SigV4 path-style GET URL against the storage host:
https://<region>.digitaloceanspaces.com/<bucket>/<object_key>
?X-Amz-Algorithm=AWS4-HMAC-SHA256
&X-Amz-Credential=<key>%2F<date>%2F<region>%2Fs3%2Faws4_request
&X-Amz-Date=<timestamp>
&X-Amz-Expires=900
&X-Amz-SignedHeaders=host
&X-Amz-Signature=<hex>
The host and bucket depend on the deployment, so read them from the URL you were given rather than hardcoding them. The shape does not change: a plain GET, no headers required, X-Amz-Expires=900.
The object key inside it is deterministic and namespaced by project:
{env}/files/{project_id}/{endpoint}/{filename}
live/files/4127/v2-perceive/per_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7_markdown.md
Signing is scoped to that prefix. A project can only ever be handed a signature over its own keys, so an object_key from someone else's project cannot be turned into a working URL.
Expiry: 15 minutes, V1 and V2#
Every signed URL EnConvert issues lives for 900 seconds. There is no request parameter to make it longer or shorter.
| Where it appears | Field |
|---|---|
| V1 sync conversion response | presigned_url |
| V1 batch status, per item | download_url |
| V1 batch status, ZIP mode | zip_download_url |
| V1 job status poll | presigned_url |
| V2 perceive, per output | outputs.<name>.url, alongside expires_in: 900 |
| V2 perceive batch, ZIP mode | zip.url |
| V2 ingest, on completion | output_url |
Signed URLs are reusable, not single use. The same URL keeps working for repeated GETs until the 15 minutes elapse. Nothing invalidates it early, and downloading it once does not consume it.
If you need the file for longer than 15 minutes, download the bytes and store them yourself. Re-signing gives you a fresh link, not permanent access.
Re-signing#
An expired link is not a lost file. Ask the API again and it mints a new signature over the same stored object:
| Job | Re-sign with |
|---|---|
| V1 async or batch conversion | GET /v1/convert/batch/{batch_id} |
| V1 sync conversion polled by your own id | GET /v1/convert/status/{job_id} |
| V2 perceive operation | GET /v2/perceive/{operation_id} |
| V2 perceive batch | GET /v2/perceive/batch/{job_id} |
| V2 ingest job | GET /v2/ingest/{job_id} |
Every one of those endpoints rebuilds the URLs from the persisted object keys on each call. Re-signing renders nothing, converts nothing, and bills no ops. It works for as long as the object is still in storage, which is the other clock on this page.
One behaviour to code for: if the signature cannot be produced, the field comes back null rather than the request failing. A status poll never returns a 500 because of a stale key. So check for null on url, download_url and output_url before you dereference them.
Retention is a different clock#
This is the distinction people get wrong, so here it is in one line each:
- Signature expiry (15 minutes) decides how long a given URL works.
- Retention (hours to days, per plan) decides how long the file exists at all.
They are independent, which means both of the confusing cases are real:
An expired URL does not mean the file is gone. Fifteen minutes after a conversion, the link is dead but the object is almost certainly still there. Poll the job again and you get a working link back.
A live URL does not guarantee the file is still there. If you re-sign a Founding-plan output at 59 minutes and use the link at 62 minutes, the retention sweep may have deleted the object in between. The signature is valid; the object is not. The download fails at the storage layer, not at the API.
Retention length is set per plan on your subscription, and the Founding plan's window is one hour, which is short enough to hit by accident during development. The per-plan table is in Rate Limits and Quotas.
Two more things about retention:
- Deletion is scheduled, then swept on an interval, so a file can outlive its window by a few minutes. Do not build on that. It is slack in the sweeper, not a grace period.
- Projects on a storage add-on do not get deletions scheduled at all. Their outputs stay until they are removed deliberately and count against the add-on's storage quota instead.
Separate from your plan's window, rendered-HTML captures used for render-quality scoring are kept for 90 days and can be turned off per request with the X-Enconvert-No-Capture: true header. Source files uploaded to POST /v2/ingest/files are deleted as soon as the JSONL is assembled, with a 24 hour backstop if something goes wrong before that.
direct_download: streaming the bytes instead#
If following a URL is an extra hop you do not want, ask for the bytes in the response body.
On V2 perceive#
direct_download: true on POST /v2/perceive replaces the JSON envelope entirely. The response body is the artifact, served with the artifact's own content type.
curl -X POST https://api.enconvert.com/v2/perceive \
-H "X-API-Key: sk_your_private_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/pricing",
"outputs": ["markdown"],
"direct_download": true
}' \
--output pricing.md
It needs exactly one artifact-producing output: markdown, html_cleaned, html_raw, screenshot, screenshot_full_page, pdf, links or images, all of which are described on the perceive page. Ask for two and the call returns 400 listing what you sent. structured does not count, because it is inline JSON rather than a stored file, so it may ride along without breaking the rule.
The metadata that would have been in the JSON body moves into headers: X-Operation-Id, X-Object-Key and X-Cache-Hit always, plus X-Render-Quality, X-Source-Status-Code, X-Content-Hash and X-Warnings-Count when those values exist. The body also carries Content-Disposition: attachment, Content-Length and Cache-Control: no-transform.
Two GET endpoints take direct_download as a query parameter:
GET /v2/perceive/{operation_id}?direct_download=true&output=markdownstreams one artifact from a past operation.outputis required when the operation produced more than one artifact, and an unknown name returns404.GET /v2/perceive/batch/{job_id}?direct_download=truestreams the batch ZIP foroutput_mode: "zip"batches whose archive is ready, and answers400otherwise.
POST /v2/perceive/batch rejects direct_download with 422. Set output_mode to "zip" and download the archive instead.
410 Gone with a message telling you to re-run the request rather than silently returning empty bytes. The saving is a round trip, not a storage write.
On V1 conversions#
V1 has a direct_download field too, with different behaviour from V2's. On the URL endpoints it decides whether the API hands back raw file bytes or a JSON body with a presigned download URL. On the file-upload endpoints it is accepted for request-shape parity but has no effect: those endpoints always answer with the JSON body.
| Endpoint type | Key type | Default | What comes back |
|---|---|---|---|
| File upload endpoints | All keys | true |
JSON with presigned_url, whatever you set. The field is inert here. |
| URL endpoints | Private key | false |
JSON with presigned_url. Set true for raw bytes. |
| URL endpoints | Public / dashboard key | true (forced) |
JSON with presigned_url |
direct_download is forced to true, but the response is a JSON object with a presigned_url (not raw bytes). This avoids reverse-proxy timeout issues with large files that can take 60 to 120 seconds to convert.
On a URL endpoint, a private key with direct_download=true returns raw file bytes:
curl -X POST https://api.enconvert.com/v1/convert/url-to-pdf \
-H "X-API-Key: sk_your_private_key" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "direct_download": true}' \
--output output.pdf
Restrictions:
direct_downloadcannot be used withasync_mode: true(returns400)direct_downloadcannot be used with multiple URLs (returns400)
Both restrictions follow from the same fact: there is no response left to put the bytes in once the call has answered 202. Async results are collected by polling or a webhook. See Sync and Async Jobs.
Response headers#
File-upload conversions, and URL conversions made with a public or dashboard key, repeat the conversion metadata in headers as well as in the JSON body:
| Header | Description |
|---|---|
X-Object-Key |
Storage path of the converted file |
X-File-Size |
Size of the converted file in bytes |
X-Conversion-Time |
Time taken for conversion in seconds |
X-Filename |
Generated filename |
A raw-bytes response (URL endpoint, private key, direct_download: true) carries those four plus Content-Disposition: attachment; filename="{filename}", Content-Length and Cache-Control: no-transform. A URL conversion that returns JSON to a private key carries none of them, so read the body.
Frequently asked questions#
How long do EnConvert signed download URLs stay valid?#
Fifteen minutes, or 900 seconds, on both V1 and V2. The V2 artifact envelope states it explicitly as expires_in: 900. There is no parameter to extend it. Re-fetch the job or operation to mint a fresh URL over the same file.
Can I use a signed URL more than once?#
Yes. Signed URLs are not single use. The same link serves repeated GETs until the 15 minutes elapse, and downloading it does not invalidate it.
My download URL expired. Has the file been deleted?#
Almost certainly not. Signature expiry and file retention are separate clocks. Poll the job or operation again (GET /v1/convert/batch/{batch_id}, GET /v2/perceive/{operation_id}, GET /v2/ingest/{job_id}) and you get a freshly signed link at no op cost, as long as the file is still inside your plan's retention window.
How long does EnConvert keep my converted files?#
Retention is set per plan, and the Founding plan keeps outputs for one hour. Paid plans keep them longer, and projects on a storage add-on are never swept at all. The per-plan numbers are in Rate Limits and Quotas.
What does a 410 Gone mean on a perceive artifact?#
The object has passed your plan's retention window and been deleted from storage. direct_download reads the artifact back out of storage before streaming it, so an aged-out artifact answers 410 rather than an empty body. Re-run the perceive request to regenerate it.
How do I get raw bytes instead of a download URL?#
Set direct_download: true. On POST /v2/perceive it requires exactly one artifact-producing output and the response body becomes that artifact. On V1 URL endpoints with a private key it returns the file bytes, and it cannot be combined with async_mode or with multiple URLs.