Spring Boot icon

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:

environment
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.

src/main/java/com/example/og/OgController.java
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.

src/main/java/com/example/og/OgController.java
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.

src/main/java/com/example/og/OgController.java
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.

src/main/java/com/example/og/OgController.java
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.

src/main/resources/templates/card.html
<!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>`.

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 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.