Phoenix
Put the render call in a controller action and your API key never reaches the browser: the browser, and every social crawler, only ever sees a URL on your own domain. Imejis returns the finished image directly in the response, so an action is a single Req.post!, with nothing to poll and no webhook to receive.
Set two environment variables first:
IMEJIS_API_KEY=your_api_key
IMEJIS_DESIGN_ID=your_design_idThe 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.
Every block below comes from a real Phoenix app that is booted in Docker and called end to end before this page ships.
Route the five actionsRoute the five actions
Phoenix keeps routing in one file, so this is where the actions below become URLs.
defmodule MyAppWeb.Router do
# In a generated app this line is `use MyAppWeb, :router`.
use Phoenix.Router
# A generated app splits this into :browser and :api; one pipeline is enough
# for these five. `plug :accepts` is not optional: without it the card action
# cannot render its template — Phoenix raises "conn.params["_format"] is not
# set".
pipeline :default do
plug :accepts, ["html", "json"]
end
scope "/", MyAppWeb do
pipe_through :default
get "/og", OgController, :show
get "/og-size", OgController, :og_size
get "/og-url", OgController, :og_url
post "/og-bulk", OgController, :og_bulk
get "/card", OgController, :card
end
endAn 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.
defmodule MyAppWeb.OgController do
# In a generated app this line is `use MyAppWeb, :controller`.
use Phoenix.Controller, formats: [:html, :json]
# Read at call time, not compile time: a module attribute would bake the
# value into the release.
defp base, do: System.get_env("IMEJIS_RENDER_URL") || "https://render.imejis.io"
defp design, do: System.fetch_env!("IMEJIS_DESIGN_ID")
defp headers do
[{"dma-api-key", System.fetch_env!("IMEJIS_API_KEY")}]
end
# The formats the render API accepts. The actions below pass `?format=`
# through when one is asked for; with none, the render host's default (jpeg)
# applies.
@formats ~w[png jpeg webp pdf]
# Render on demand and send the image straight back. The key stays on the
# server, so it never reaches the browser — point an <img src>, or an
# og:image, at this action.
#
# `?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.
def show(conn, params) do
requested = Map.get(params, "format", "")
query = if requested in @formats, do: "?format=#{requested}", else: ""
response =
Req.post!("#{base()}/v1/#{design()}#{query}",
headers: headers(),
json: %{
"headline.text" => Map.get(params, "title", "Hello from Phoenix"),
"subtitle.text" => Map.get(params, "subtitle", "")
}
)
case response.status do
200 ->
conn
# The third argument is the charset, and `nil` means none: an image or
# a PDF is bytes, not text, so `application/pdf; charset=utf-8` would
# be a claim about it that is not true.
|> put_resp_content_type(
Req.Response.get_header(response, "content-type") |> List.first() ||
"image/jpeg",
nil
)
# Renders are deterministic, so cache aggressively at the edge/CDN —
# and every request to the render API is one metered render.
|> put_resp_header(
"cache-control",
"public, immutable, no-transform, max-age=31536000"
)
|> send_resp(200, response.body)
status ->
send_resp(conn, 502, "render failed: #{status}")
end
end
endOne 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.
defmodule MyAppWeb.OgController do
# In a generated app this line is `use MyAppWeb, :controller`.
use Phoenix.Controller, formats: [:html, :json]
# Read at call time, not compile time: a module attribute would bake the
# value into the release.
defp base, do: System.get_env("IMEJIS_RENDER_URL") || "https://render.imejis.io"
defp design, do: System.fetch_env!("IMEJIS_DESIGN_ID")
defp headers do
[{"dma-api-key", System.fetch_env!("IMEJIS_API_KEY")}]
end
# The formats the render API accepts. The actions below pass `?format=`
# through when one is asked for; with none, the render host's default (jpeg)
# applies.
@formats ~w[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.
#
# nil is the base canvas: a render with no `variation` at all.
defp sizes, do: %{"og" => nil, "story" => System.get_env("IMEJIS_VARIATION_ID", "story")}
def og_size(conn, params) do
size = Map.get(params, "size", "og")
case Map.fetch(sizes(), size) do
:error ->
send_resp(conn, 404, "unknown size: #{size}")
{:ok, variation} ->
requested = Map.get(params, "format", "")
quality = params |> Map.get("quality", "") |> Integer.parse()
query =
(if variation, do: [variation: variation], else: []) ++
if(requested in @formats, do: [format: requested], else: []) ++
case quality do
{n, ""} when n >= 1 and n <= 100 -> [quality: n]
_ -> []
end
response =
Req.post!("#{base()}/v1/#{design()}",
headers: headers(),
params: query,
json: %{"headline.text" => Map.get(params, "title", "Every size")}
)
case response.status do
200 ->
conn
|> put_resp_content_type(
Req.Response.get_header(response, "content-type") |> List.first() ||
"image/jpeg",
nil
)
|> put_resp_header(
"cache-control",
"public, immutable, no-transform, max-age=31536000"
)
|> send_resp(200, response.body)
status ->
send_resp(conn, 502, "render failed: #{status}")
end
end
end
endAn 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.
defmodule MyAppWeb.OgController do
# In a generated app this line is `use MyAppWeb, :controller`.
use Phoenix.Controller, formats: [:html, :json]
# Read at call time, not compile time: a module attribute would bake the
# value into the release.
defp base, do: System.get_env("IMEJIS_RENDER_URL") || "https://render.imejis.io"
defp design, do: System.fetch_env!("IMEJIS_DESIGN_ID")
defp headers do
[{"dma-api-key", System.fetch_env!("IMEJIS_API_KEY")}]
end
# The formats the render API accepts. The actions below pass `?format=`
# through when one is asked for; with none, the render host's default (jpeg)
# applies.
@formats ~w[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).
def og_url(conn, params) do
delivery = if params["private"], do: "signed", else: "hosted"
# 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.
requested = Map.get(params, "format", "")
query =
[delivery: delivery] ++
if(requested in @formats, do: [format: requested], else: []) ++
if delivery == "signed", do: [expiresIn: 30], else: []
# %{"url" => …, "format" => …, "file" => %{"size" => …}, "expiresAt" => …}
stored =
Req.post!("#{base()}/v1/#{design()}",
headers: headers(),
params: query,
json: %{"headline.text" => Map.get(params, "title", "Shared card")}
).body
json(conn, %{
url: stored["url"],
format: stored["format"],
expiresAt: stored["expiresAt"]
})
end
endBulk 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.
defmodule MyAppWeb.OgController do
# In a generated app this line is `use MyAppWeb, :controller`.
use Phoenix.Controller, formats: [:html, :json]
# Read at call time, not compile time: a module attribute would bake the
# value into the release.
defp base, do: System.get_env("IMEJIS_RENDER_URL") || "https://render.imejis.io"
defp design, do: System.fetch_env!("IMEJIS_DESIGN_ID")
defp headers do
[{"dma-api-key", System.fetch_env!("IMEJIS_API_KEY")}]
end
# The formats the render API accepts. The actions below pass `?format=`
# through when one is asked for; with none, the render host's default (jpeg)
# applies.
@formats ~w[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.
def og_bulk(conn, params) do
rows = Map.get(params, "rows", [])
bulk =
Req.post!("#{base()}/bulk",
headers: headers(),
json: %{
designId: design(),
delivery: "hosted",
format: "png",
data:
Enum.map(rows, fn row ->
%{"headline.text" => row["name"], "subtitle.text" => row["role"] || ""}
end)
}
).body
json(conn, %{
summary: bulk["summary"],
urls: Enum.map(bulk["results"], & &1["url"])
})
end
endUsing 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.
<%!--
og:image points at YOUR OWN action, so crawlers fetch your domain and get the
absolute URL they need. In a real layout this is where the assigns' title
goes: ~p"/og?title=#{@post.title}" with your endpoint's host.
--%>
<head>
<meta property="og:image" content={@og_image} />
<meta name="twitter:card" content="summary_large_image" />
</head>
<%!--
For a PUBLIC design you can skip the proxy and point an <img> straight at the
render URL — but the key then ships in the markup, so it has to be one you
are happy to expose. Keep the proxy whenever it must stay private: that is
the right default, and what the show action above is for.
--%>
<img src={@direct} width="1200" height="630" alt={"Card for #{@title}"} />Dynamic OG images. The card action above is the whole link-preview pattern, and MyAppWeb.Endpoint.url() is what makes og:image the absolute URL a crawler needs. In a HEEx template you would set it from the assigns, as <meta property="og:image" content={~p"/og?title=#{@post.title}"} /> with your endpoint's host, and X, LinkedIn, Slack and iMessage then fetch your domain and get a freshly rendered 1200×630 image for that post.
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>`.
| Parameter | Values | Default | What it does |
|---|---|---|---|
?format= | png · jpeg · webp · pdf | jpeg | Output image format. |
?quality= | 1–100 | none | Compression quality for lossy output; ignored for other formats. (JPEG only) |
?delivery= | stream · hosted · signed | stream | stream = raw image bytes (default); hosted = store + return a public URL; signed = store + return a time-limited URL. |
?expiresIn= | minutes | 60 | Signed-URL lifetime in minutes (default 60, max 10080 = 7 days). (delivery=signed only) |
?variation= | <size id> | master | Which 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.
No Elixir-only page exists yet; the cURL page is the same requests as plain HTTP. The router needs plug :accepts for the card action to render its HEEx template, and put_resp_content_type/3 takes nil as the charset, because an image is bytes and image/jpeg; charset=utf-8 is a claim that is not true. One render per request, 100 free a month, see pricing.