Spring Boot
Put the render call in a controller 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 endpoint is a single HttpClient call, with nothing to poll and no webhook to receive.
You need spring-boot-starter-web and two environment variables:
IMEJIS_API_KEY=your_api_key
IMEJIS_DESIGN_ID=your_design_id@Value binds them straight onto the fields, so the key never appears in a handler body.
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.
Every block below comes from a real Spring Boot app that is booted on a random port 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.
package com.example.og;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.ui.Model;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.time.Duration;
import com.fasterxml.jackson.databind.ObjectMapper;
// @Controller, not @RestController: the card action returns a TEMPLATE, and
// @RestController would write the view name to the response body instead of
// rendering it. The JSON actions carry @ResponseBody themselves.
@Controller
public class OgController {
// The formats the render API accepts. The endpoints below pass `?format=`
// through when one is asked for; with none, the render host's default (jpeg)
// applies.
private static final List<String> FORMATS = List.of("png", "jpeg", "webp", "pdf");
private final HttpClient http = HttpClient.newHttpClient();
private final ObjectMapper json = new ObjectMapper();
// Injected from the environment, so the key never appears in code.
@Value("${IMEJIS_API_KEY}")
private String apiKey;
@Value("${IMEJIS_DESIGN_ID}")
private String designId;
@Value("${IMEJIS_RENDER_URL:https://render.imejis.io}")
private String base;
/** POST to the render API with the key header and a JSON body of overrides. */
private HttpResponse<byte[]> render(String path, String query, Map<String, Object> body)
throws Exception {
HttpRequest request =
HttpRequest.newBuilder(URI.create(base + path + (query.isEmpty() ? "" : "?" + query)))
.header("dma-api-key", apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body)))
.build();
return http.send(request, HttpResponse.BodyHandlers.ofByteArray());
}
// 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 endpoint.
//
// `?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.
@GetMapping("/og")
public ResponseEntity<byte[]> show(
@RequestParam(defaultValue = "Hello from Spring Boot") String title,
@RequestParam(defaultValue = "") String subtitle,
@RequestParam(defaultValue = "") String format)
throws Exception {
String query = FORMATS.contains(format) ? "format=" + format : "";
HttpResponse<byte[]> upstream =
render("/v1/" + designId, query, Map.of("headline.text", title, "subtitle.text", subtitle));
if (upstream.statusCode() != 200) {
return ResponseEntity.status(502).build();
}
String type =
upstream.headers().firstValue("content-type").orElse(MediaType.IMAGE_JPEG_VALUE);
return ResponseEntity.ok()
.header("Content-Type", type)
// Renders are deterministic, so cache aggressively at the edge/CDN —
// and every request to the render API is one metered render.
.header("Cache-Control", "public, immutable, no-transform, max-age=31536000")
.body(upstream.body());
}
}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.
package com.example.og;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.ui.Model;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.time.Duration;
import com.fasterxml.jackson.databind.ObjectMapper;
// @Controller, not @RestController: the card action returns a TEMPLATE, and
// @RestController would write the view name to the response body instead of
// rendering it. The JSON actions carry @ResponseBody themselves.
@Controller
public class OgController {
// The formats the render API accepts. The endpoints below pass `?format=`
// through when one is asked for; with none, the render host's default (jpeg)
// applies.
private static final List<String> FORMATS = List.of("png", "jpeg", "webp", "pdf");
private final HttpClient http = HttpClient.newHttpClient();
private final ObjectMapper json = new ObjectMapper();
// Injected from the environment, so the key never appears in code.
@Value("${IMEJIS_API_KEY}")
private String apiKey;
@Value("${IMEJIS_DESIGN_ID}")
private String designId;
@Value("${IMEJIS_RENDER_URL:https://render.imejis.io}")
private String base;
/** POST to the render API with the key header and a JSON body of overrides. */
private HttpResponse<byte[]> render(String path, String query, Map<String, Object> body)
throws Exception {
HttpRequest request =
HttpRequest.newBuilder(URI.create(base + path + (query.isEmpty() ? "" : "?" + query)))
.header("dma-api-key", apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body)))
.build();
return http.send(request, HttpResponse.BodyHandlers.ofByteArray());
}
// 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.
@Value("${IMEJIS_VARIATION_ID:story}")
private String storyVariation;
@GetMapping("/og-size")
public ResponseEntity<byte[]> ogSize(
@RequestParam(defaultValue = "og") String size,
@RequestParam(defaultValue = "Every size") String title,
@RequestParam(defaultValue = "") String format,
@RequestParam(defaultValue = "0") int quality)
throws Exception {
// An absent key is an unknown size; the base canvas maps to "", which is
// a render with no `variation` at all.
Map<String, String> sizes = Map.of("og", "", "story", storyVariation);
if (!sizes.containsKey(size)) {
return ResponseEntity.notFound().build();
}
StringBuilder query = new StringBuilder();
if (!sizes.get(size).isEmpty()) {
query.append("variation=").append(sizes.get(size));
}
if (FORMATS.contains(format)) {
if (query.length() > 0) query.append("&");
query.append("format=").append(format);
}
if (quality >= 1 && quality <= 100) {
if (query.length() > 0) query.append("&");
query.append("quality=").append(quality);
}
HttpResponse<byte[]> upstream =
render("/v1/" + designId, query.toString(), Map.of("headline.text", title));
if (upstream.statusCode() != 200) {
return ResponseEntity.status(502).build();
}
String type =
upstream.headers().firstValue("content-type").orElse(MediaType.IMAGE_JPEG_VALUE);
return ResponseEntity.ok()
.header("Content-Type", type)
.header("Cache-Control", "public, immutable, no-transform, max-age=31536000")
.body(upstream.body());
}
}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.
package com.example.og;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.ui.Model;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.time.Duration;
import com.fasterxml.jackson.databind.ObjectMapper;
// @Controller, not @RestController: the card action returns a TEMPLATE, and
// @RestController would write the view name to the response body instead of
// rendering it. The JSON actions carry @ResponseBody themselves.
@Controller
public class OgController {
// The formats the render API accepts. The endpoints below pass `?format=`
// through when one is asked for; with none, the render host's default (jpeg)
// applies.
private static final List<String> FORMATS = List.of("png", "jpeg", "webp", "pdf");
private final HttpClient http = HttpClient.newHttpClient();
private final ObjectMapper json = new ObjectMapper();
// Injected from the environment, so the key never appears in code.
@Value("${IMEJIS_API_KEY}")
private String apiKey;
@Value("${IMEJIS_DESIGN_ID}")
private String designId;
@Value("${IMEJIS_RENDER_URL:https://render.imejis.io}")
private String base;
/** POST to the render API with the key header and a JSON body of overrides. */
private HttpResponse<byte[]> render(String path, String query, Map<String, Object> body)
throws Exception {
HttpRequest request =
HttpRequest.newBuilder(URI.create(base + path + (query.isEmpty() ? "" : "?" + query)))
.header("dma-api-key", apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body)))
.build();
return http.send(request, HttpResponse.BodyHandlers.ofByteArray());
}
// Ask for a stored URL instead of bytes. `hosted` is public and permanent;
// `signed` is private and expires (`expiresIn` is minutes).
@GetMapping("/og-url")
@ResponseBody
public Map<String, Object> ogUrl(
@RequestParam(defaultValue = "Shared card") String title,
@RequestParam(name = "private", defaultValue = "false") boolean isPrivate,
@RequestParam(defaultValue = "") String format)
throws Exception {
String delivery = isPrivate ? "signed" : "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.
String chosen = FORMATS.contains(format) ? "&format=" + format : "";
String query = "delivery=" + delivery + chosen + (isPrivate ? "&expiresIn=30" : "");
// { url, format, file: { size }, expiresAt (signed only) }
Map<String, Object> stored =
json.readValue(
render("/v1/" + designId, query, Map.of("headline.text", title)).body(), Map.class);
return Map.of(
"url", stored.getOrDefault("url", ""),
"format", stored.getOrDefault("format", ""),
"expiresAt", stored.getOrDefault("expiresAt", ""));
}
}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.
package com.example.og;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.ui.Model;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.time.Duration;
import com.fasterxml.jackson.databind.ObjectMapper;
// @Controller, not @RestController: the card action returns a TEMPLATE, and
// @RestController would write the view name to the response body instead of
// rendering it. The JSON actions carry @ResponseBody themselves.
@Controller
public class OgController {
// The formats the render API accepts. The endpoints below pass `?format=`
// through when one is asked for; with none, the render host's default (jpeg)
// applies.
private static final List<String> FORMATS = List.of("png", "jpeg", "webp", "pdf");
private final HttpClient http = HttpClient.newHttpClient();
private final ObjectMapper json = new ObjectMapper();
// Injected from the environment, so the key never appears in code.
@Value("${IMEJIS_API_KEY}")
private String apiKey;
@Value("${IMEJIS_DESIGN_ID}")
private String designId;
@Value("${IMEJIS_RENDER_URL:https://render.imejis.io}")
private String base;
/** POST to the render API with the key header and a JSON body of overrides. */
private HttpResponse<byte[]> render(String path, String query, Map<String, Object> body)
throws Exception {
HttpRequest request =
HttpRequest.newBuilder(URI.create(base + path + (query.isEmpty() ? "" : "?" + query)))
.header("dma-api-key", apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body)))
.build();
return http.send(request, HttpResponse.BodyHandlers.ofByteArray());
}
// One POST renders many images and returns a URL for each, in the order
// sent — a row per record, or one per size.
@PostMapping("/og-bulk")
@ResponseBody
public Map<String, Object> ogBulk(@RequestBody Map<String, List<Map<String, String>>> payload)
throws Exception {
List<Map<String, String>> rows = payload.getOrDefault("rows", List.of());
Map<String, Object> bulk =
json.readValue(
render(
"/bulk",
"",
Map.of(
"designId", designId,
"delivery", "hosted",
"format", "png",
"data",
rows.stream()
.map(
row ->
Map.of(
"headline.text", row.get("name"),
"subtitle.text", row.getOrDefault("role", "")))
.toList()))
.body(),
Map.class);
List<Map<String, Object>> results = (List<Map<String, Object>>) bulk.get("results");
return Map.of(
"summary", bulk.get("summary"),
"urls", results.stream().map(result -> result.getOrDefault("url", "")).toList());
}
}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.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<!--
og:image points at YOUR OWN endpoint, so crawlers fetch your domain and
get the absolute URL they need. In a real template this is where the
model's title goes: @{/og(title=${post.title})}.
-->
<meta property="og:image" th:content="${ogImage}" />
<meta name="twitter:card" content="summary_large_image" />
</head>
<body>
<!--
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 /og above is for.
-->
<img th:src="${direct}" width="1200" height="630" th:alt="'Card for ' + ${title}" />
</body>
</html>Dynamic OG images. The /card endpoint above is the whole link-preview pattern, and ServletUriComponentsBuilder is what makes og:image the absolute URL a crawler needs. In a Thymeleaf template you would set it from the model, as <meta property="og:image" th:content="@{/og(title=${post.title})}">, 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.
The same calls outside Spring are on the Java page. The class is a @Controller with @ResponseBody on the JSON endpoints rather than a @RestController, because the card action returns a template and a @RestController would write the view name into the response body. ResponseEntity carries the whole image, so it buffers. Each request is one render, 100 free a month, then pricing.