Fastify icon

Fastify

Put the render call behind a Fastify route and your API key never reaches the browser. Imejis returns the finished image directly in the response, so the handler is a single upstream request, with nothing to poll and no webhook to receive.

Set two environment variables first: IMEJIS_API_KEY (from your dashboard) and IMEJIS_DESIGN_ID (any design's id). The fields you override are component.property pairs: headline.text, title.color, image.image; only what you send changes, and a design's overridable fields are listed in its Share / API panel.

Each route below comes from a real Fastify app that is booted in Docker and called end to end before this page ships.

An endpoint that returns the generated imageAn endpoint that returns the generated image

A route in your app that renders on demand and streams the image straight back, so your render key never reaches the browser. This is the shape to use behind an <img src> on a page you control. ?format= picks png, jpeg, webp or pdf, and the route answers with the content type that matches it. A PDF labelled image/png is a file the browser, the email client and Slack all refuse to open.

Fastify: An endpoint that returns the generated image
import Fastify from "fastify"
 
const app = Fastify()
 
const API_KEY = process.env.IMEJIS_API_KEY
const DESIGN_ID = process.env.IMEJIS_DESIGN_ID
const BASE = process.env.IMEJIS_RENDER_URL ?? "https://render.imejis.io"
const HEADERS = { "dma-api-key": API_KEY, "Content-Type": "application/json" }
// The formats the render API accepts. The routes below pass `?format=`
// through when one is asked for; with none, the render host's default (jpeg)
// applies.
const FORMATS = ["png", "jpeg", "webp", "pdf"]
 
// Render on demand and stream the image straight back. The render key stays on
// the server, so it never reaches the browser — point an <img src> at this.
//
// `?format=` decides what comes back — png · jpeg · webp · pdf — and with no
// format the render host's default (jpeg) applies. Either way the response
// carries whatever type the render API set, passed straight through: a PDF
// labelled `image/jpeg` is a file the browser, the email client and Slack all
// refuse to open.
app.get("/og", async (request, reply) => {
  const params = new URLSearchParams()
  if (FORMATS.includes(request.query.format)) {
    params.set("format", request.query.format)
  }
 
  const upstream = await fetch(`${BASE}/v1/${DESIGN_ID}?${params}`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({
      "headline.text": request.query.title ?? "Hello from Fastify",
      "subtitle.text": request.query.subtitle ?? "",
    }),
  })
  if (!upstream.ok) {
    return reply.code(502).send({ error: `render failed: ${upstream.status}` })
  }
  return reply
    .type(upstream.headers.get("content-type") ?? "image/jpeg")
    // Cache it: the same query renders the same image, and every request to
    // the render API is one metered render.
    .header("Cache-Control", "public, immutable, no-transform, max-age=31536000")
    .send(Buffer.from(await upstream.arrayBuffer()))
})
 
await app.listen({ port: 3000 })
console.log("listening on http://localhost:3000")

One endpoint every size and jpeg qualityOne endpoint, every size, and JPEG quality

A multi-size design carries several canvases: an OG image, a square post, a vertical story. ?variation=<id> picks which one the route renders, and omitting it gives the base size, so one endpoint serves every size your pages ask for. ?quality= trades file size for fidelity on JPEG only; png, webp and pdf ignore it.

Fastify: One endpoint, every size, and JPEG quality
import Fastify from "fastify"
 
const app = Fastify()
 
const API_KEY = process.env.IMEJIS_API_KEY
const DESIGN_ID = process.env.IMEJIS_DESIGN_ID
const BASE = process.env.IMEJIS_RENDER_URL ?? "https://render.imejis.io"
const HEADERS = { "dma-api-key": API_KEY, "Content-Type": "application/json" }
// The formats the render API accepts. The routes below pass `?format=`
// through when one is asked for; with none, the render host's default (jpeg)
// applies.
const FORMATS = ["png", "jpeg", "webp", "pdf"]
 
// One design, several canvases — an OG image, a square post, a vertical
// story. `?variation=<id>` picks which one you get; with none you get the
// base size. Map YOUR OWN size names to the ids (the design's Share / API
// panel lists them) so a page asks for "story", not for an id.
//
// `?quality=` trades file size for fidelity on JPEG only — png, webp and pdf
// ignore it.
const SIZES = { og: null, story: process.env.IMEJIS_VARIATION_ID ?? "story" }
 
app.get("/og-size", async (request, reply) => {
  const size = request.query.size ?? "og"
  if (!(size in SIZES)) {
    return reply.code(404).send({ error: `unknown size: ${size}` })
  }
 
  const params = new URLSearchParams()
  if (SIZES[size]) params.set("variation", SIZES[size])
  if (FORMATS.includes(request.query.format)) {
    params.set("format", request.query.format)
  }
  const quality = Number(request.query.quality)
  if (quality >= 1 && quality <= 100) params.set("quality", String(quality))
 
  const upstream = await fetch(`${BASE}/v1/${DESIGN_ID}?${params}`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({
      "headline.text": request.query.title ?? "Every size",
    }),
  })
  if (!upstream.ok) {
    return reply.code(502).send({ error: `render failed: ${upstream.status}` })
  }
  return reply
    .type(upstream.headers.get("content-type") ?? "image/jpeg")
    .header("Cache-Control", "public, immutable, no-transform, max-age=31536000")
    .send(Buffer.from(await upstream.arrayBuffer()))
})
 
await app.listen({ port: 3000 })
console.log("listening on http://localhost:3000")

An endpoint that returns a hosted or signed image urlAn endpoint that returns a hosted or signed image URL

Ask for ?delivery=hosted (a public URL) or ?delivery=signed (a private, expiring one) and hand the URL to the caller instead of the bytes, which is what you want for an email, a webhook payload, or a share card.

Fastify: An endpoint that returns a hosted or signed image URL
import Fastify from "fastify"
 
const app = Fastify()
 
const API_KEY = process.env.IMEJIS_API_KEY
const DESIGN_ID = process.env.IMEJIS_DESIGN_ID
const BASE = process.env.IMEJIS_RENDER_URL ?? "https://render.imejis.io"
const HEADERS = { "dma-api-key": API_KEY, "Content-Type": "application/json" }
// The formats the render API accepts. The routes below pass `?format=`
// through when one is asked for; with none, the render host's default (jpeg)
// applies.
const FORMATS = ["png", "jpeg", "webp", "pdf"]
 
// Ask for a stored URL instead of bytes. `hosted` is public and permanent;
// `signed` is private and expires (`expiresIn` is minutes).
app.get("/og-url", async (request) => {
  const delivery = request.query.private ? "signed" : "hosted"
  const params = new URLSearchParams({ delivery })
  // The stored URL carries the extension of the format it was rendered in, so
  // pass the format through here too — a recipient that sniffs by extension
  // (an email client, a Slack unfurl) needs `.pdf` to say pdf.
  if (FORMATS.includes(request.query.format)) {
    params.set("format", request.query.format)
  }
  if (delivery === "signed") params.set("expiresIn", "30")
 
  const upstream = await fetch(`${BASE}/v1/${DESIGN_ID}?${params}`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({ "headline.text": request.query.title ?? "Shared card" }),
  })
  const stored = await upstream.json()
  return {
    url: stored.url,
    format: stored.format,
    expiresAt: stored.expiresAt ?? null,
  }
})
 
await app.listen({ port: 3000 })
console.log("listening on http://localhost:3000")

Bulk image generation from a routeBulk image generation from a route

One POST to /bulk renders many images, a row per record or one per size, and returns a URL for each, in the order you sent them.

Fastify: Bulk image generation from a route
import Fastify from "fastify"
 
const app = Fastify()
 
const API_KEY = process.env.IMEJIS_API_KEY
const DESIGN_ID = process.env.IMEJIS_DESIGN_ID
const BASE = process.env.IMEJIS_RENDER_URL ?? "https://render.imejis.io"
const HEADERS = { "dma-api-key": API_KEY, "Content-Type": "application/json" }
// The formats the render API accepts. The routes below pass `?format=`
// through when one is asked for; with none, the render host's default (jpeg)
// applies.
const FORMATS = ["png", "jpeg", "webp", "pdf"]
 
// One POST renders many images and returns a URL for each, in the order sent —
// a row per record, or one per size.
app.post("/cards", async (request) => {
  const rows = request.body.rows ?? []
  const upstream = await fetch(`${BASE}/bulk`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({
      designId: DESIGN_ID,
      delivery: "hosted",
      format: "png",
      data: rows.map((row) => ({
        "headline.text": row.name,
        "subtitle.text": row.role,
      })),
    }),
  })
  const bulk = await upstream.json()
  return { summary: bulk.summary, urls: bulk.results.map((r) => r.url ?? null) }
})
 
await app.listen({ port: 3000 })
console.log("listening on http://localhost:3000")

Using the image url directly in a templateUsing the image URL directly in a template

For a public design you can skip the proxy entirely and point an <img> at the render URL with the field values in the query string. Keep the proxy when the key must stay private. That is the right default.

Fastify: Using the image URL directly in a template
import Fastify from "fastify"
 
const app = Fastify()
 
const API_KEY = process.env.IMEJIS_API_KEY
const DESIGN_ID = process.env.IMEJIS_DESIGN_ID
const BASE = process.env.IMEJIS_RENDER_URL ?? "https://render.imejis.io"
const HEADERS = { "dma-api-key": API_KEY, "Content-Type": "application/json" }
// The formats the render API accepts. The routes below pass `?format=`
// through when one is asked for; with none, the render host's default (jpeg)
// applies.
const FORMATS = ["png", "jpeg", "webp", "pdf"]
 
// For a PUBLIC design you can skip the proxy and point an <img> straight at
// the render URL. Keep the proxy whenever the key must stay private — that is
// the right default, and what /og above is for.
app.get("/card", async (request, reply) => {
  const url = new URL(`${BASE}/v1/${DESIGN_ID}`)
  url.searchParams.set("dma-api-key", API_KEY)
  url.searchParams.set("headline.text", request.query.title ?? "Straight from a URL")
  return reply
    .type("text/html")
    .send(`<img src="${url}" width="1200" height="630" alt="Generated card" />`)
})
 
await app.listen({ port: 3000 })
console.log("listening on http://localhost:3000")

The response is the raw image (`image/jpeg` by default). Choose the format with `?format=png|jpeg|webp|pdf` (default jpeg) and set JPEG quality with `?quality=1-100`. Store the result and get a URL back with `?delivery=hosted` (or `?delivery=signed&expiresIn=60`), and render a specific size of a multi-size design with `?variation=<id>`.

ParameterValuesDefaultWhat it does
?format=png · jpeg · webp · pdfjpegOutput image format.
?quality=1–100noneCompression quality for lossy output; ignored for other formats. (JPEG only)
?delivery=stream · hosted · signedstreamstream = raw image bytes (default); hosted = store + return a public URL; signed = store + return a time-limited URL.
?expiresIn=minutes60Signed-URL lifetime in minutes (default 60, max 10080 = 7 days). (delivery=signed only)
?variation=<size id>masterWhich multi-dimension SIZE to render. Omit or use "master" for the base size; an unknown id falls back to the master. (multi-size designs only)
  • PNG (?format=png): Lossless, supports transparency. Largest file.
  • JPEG (?format=jpeg): Smaller, lossy, no transparency. Quality adjustable.
  • WebP (?format=webp): Smallest, supports transparency. Modern browsers.
  • PDF (?format=pdf): Vector page, for print and documents.

See the delivery modes and generate images with the API guides.

The same five routes written for Express are next door, and the framework-free version is on the JavaScript page. reply.send here gets a whole Buffer, so a large PDF is held in memory before it goes out. Each request renders again and counts as one render, so put a CDN in front of it; the free tier is 100 renders a month and pricing has the paid tiers.