Image API Delivery: Stream, Hosted or Signed
Image API delivery is a per-request choice between three answers to the same render: hand back the bytes, hand back a permanent URL, or hand back a URL that stops working. The design doesn't change. Only the shape of the response does.
Most people never change it off the default, then hit a bill they didn't expect or an email that leaks a customer's invoice a year later. Both are the same mistake: picking the transport before knowing who reads the image and for how long.
Image api delivery in one tableImage API delivery, in one table
stream | hosted | signed | |
|---|---|---|---|
| Response body | The image bytes | JSON with a URL | JSON with a URL |
| URL lifetime | None, there's no URL | Permanent | 60 min default, 7 days max |
| Serves from cache | Yes, on an identical payload | No, always renders fresh | No, always renders fresh |
| Who should read it | Your own backend | Anyone with the link | One recipient, for a while |
| Typical use | Save to disk, pipe to a user | README cards, docs, blog images | Invoices, certificates, receipts |
Everything below is the reasoning behind those rows.
Stream is the default and the only one that can hit cacheStream is the default, and the only one that can hit cache
Send nothing and you get bytes:
curl -X POST 'https://render.imejis.io/v1/YOUR_DESIGN_ID' \
-H 'dma-api-key: YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{ "headline": { "text": "Q3 results" } }' \
--output card.pngTwo things are true of this path and not the other two.
It can be served from cache. The renderer keys a cache on the design plus the exact payload. Ask twice for the same thing and the second answer skips rendering. That's a latency win, not a billing one, which the cost section gets to.
Advisory warnings ride in a header. The body is the image, so there's nowhere to put a note in it. If a fontFamily fell back or an override key matched no dynamic component, the response carries an x-dma-warnings header. It's a URI-encoded JSON array and the render still succeeded. Nothing breaks if you ignore it, and reading it in staging catches the typo where an override silently did nothing.
Stream is right when your own code is the reader: writing to disk, piping to a response, pushing into object storage you already run.
Hosted returns a url not a pictureHosted returns a URL, not a picture
Add delivery=hosted and the response changes shape completely:
{
"success": true,
"delivery": "hosted",
"url": "https://storage.googleapis.com/hosted-exports/...png",
"format": "png",
"file": { "path": "hosted-exports/DESIGN/1757040000000.png", "size": 184213 }
}That's JSON, not bytes, so a client written to expect binary needs a branch. Worth checking before you flip a production call over.
The URL is permanent and public. Anyone holding it can open it, and nothing revokes it, so hosted is for images that were always going to be public: a card in a README, a diagram in documentation, an open graph image. It's the right default for anything with many readers, because those readers hit storage instead of the render endpoint.
Signed expires and 7 days is the ceilingSigned expires, and 7 days is the ceiling
Same JSON, one extra field:
{
"success": true,
"delivery": "signed",
"url": "https://storage.googleapis.com/signed-exports/...pdf",
"expiresAt": "2026-09-13T15:04:00.000Z",
"format": "pdf"
}The default lifetime is 60 minutes. You can raise it with expiresIn, in minutes, up to 10,080, which is 7 days. That maximum is a hard bound, not a suggestion, so a design that assumes a 30-day link needs re-architecting rather than a longer number.
Pick a lifetime from the reader's behaviour, not from caution:
- 60 minutes: an in-app download the user clicked for just now.
- 24 hours: an email they'll open this evening.
- 7 days: an invoice or certificate you'd rather not host forever, with a re-render available from your own dashboard when they come back later.
The expiresAt timestamp is there so your code can decide. Store it next to the record and re-render on demand once it's passed, rather than emailing a link that quietly stops working.
The cost model doesnt changeThe cost model doesn't change
All three cost one render. There is no cheaper mode.
Two details decide the actual bill, and neither is about which mode you picked:
A cached response still costs a render. The quota is consumed when the API key is checked, and that happens before the cache lookup. Cache saves you time, not allowance.
Hosted and signed never touch the cache at all. They need the rendered buffer to upload it, so they always render fresh. We'd rather say that plainly than let you find it on an invoice. Calling hosted delivery in a loop with an identical payload is the most expensive thing you can do with this API, and it looks harmless in code.
So the saving comes from call frequency. A hosted URL in a page read 50,000 times is one render. A stream URL in the same <img> tag is 50,000. If you want the full version of that arithmetic, cutting render cost at scale works through it, and render quota covers the two counters and how they reset.
Caching in front of itCaching in front of it
A hosted URL is a normal HTTP resource, so ordinary caching applies: put a CDN in front of it and the rules in RFC 9111 do what they always do. A signed URL is a poor CDN citizen by design, since the thing making it private is the part that expires. Don't cache those at the edge.
Picking in one line eachPicking, in one line each
- Your backend is the reader. Stream.
- Many readers, public content. Hosted, rendered once on a schedule.
- One recipient, private content. Signed, with a lifetime that matches how they'll actually read it.
- Not sure yet. Stream, and revisit when a page starts getting traffic.
As of September 2026 all three are on every plan, including a permanent free tier: 100 renders a month. The API docs have the exact parameters, and generating images with the API is the shorter version if you're setting up the first call today.