WordPress
Give every post its own on-brand social image from one Imejis template, with no page builder and no key in the browser: the plugin below renders server-side through a REST route on your own domain, and sets og:image on every single post.
Add your credentials to wp-config.php first:
define('IMEJIS_API_KEY', 'your_api_key');
define('IMEJIS_DESIGN_ID', 'your_design_id');Then drop the code below into wp-content/plugins/imejis-og/imejis-og.php and activate it. A plugin rather than functions.php so it survives a theme switch; the same code works pasted into a theme's functions.php if you prefer.
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 WordPress install (with this plugin activated) that is booted in Docker and called end to end before this page ships.
The plugin header and the two helpersThe plugin header and the two helpers
The top of the file. Everything below is added to the same plugin, and each block uses these two helpers. The second one is the part people leave out: wp_remote_post returns a WP_Error on a transport failure, and an empty body would otherwise be served, and cached, as an "image".
<?php
/**
* Plugin Name: Imejis OG images
* Description: Renders social images from an Imejis design, server-side.
* Version: 1.0.0
*/
// Add your credentials to wp-config.php:
// define('IMEJIS_API_KEY', 'your_api_key');
// define('IMEJIS_DESIGN_ID', 'your_design_id');
// A multi-size design's second canvas, for the /og-size route below:
// define('IMEJIS_VARIATION_ID', 'your_variation_id');
// 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 IMEJIS_FORMATS = ['png', 'jpeg', 'webp', 'pdf'];
function imejis_base() {
return defined('IMEJIS_RENDER_URL') ? IMEJIS_RENDER_URL : 'https://render.imejis.io';
}
/** POST to the render API with the key header and a JSON body of overrides. */
function imejis_render($path, $query, $fields) {
return wp_remote_post(imejis_base() . $path . '?' . http_build_query($query), [
'headers' => [
'dma-api-key' => IMEJIS_API_KEY,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode($fields),
'timeout' => 30,
]);
}
/** True when the render came back with a usable body. */
function imejis_failed($response) {
return is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200;
}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.
// A REST route that renders on demand and returns the image itself, so the key
// stays on the server — point an <img src>, or an og:image, at this URL.
add_action('rest_api_init', function () {
register_rest_route('imejis/v1', '/og', [
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => function (WP_REST_Request $request) {
// `?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 nothing will open.
$requested = $request->get_param('format') ?: '';
$query = in_array($requested, IMEJIS_FORMATS, true)
? ['format' => $requested]
: [];
$response = imejis_render('/v1/' . IMEJIS_DESIGN_ID, $query, [
'headline.text' => sanitize_text_field(
$request->get_param('title') ?: get_bloginfo('name')
),
]);
// Check this. A transport failure returns a WP_Error, and an empty
// body would otherwise be served — and cached — as an "image".
if (imejis_failed($response)) {
return new WP_Error('imejis_render_failed', 'Render failed', ['status' => 502]);
}
header('Content-Type: ' . (wp_remote_retrieve_header($response, 'content-type') ?: 'image/jpeg'));
// 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');
echo wp_remote_retrieve_body($response);
exit;
},
]);
});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.
// 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 template asks for "story", not for an id.
//
// `?quality=` trades file size for fidelity on JPEG only — png, webp and pdf
// ignore it.
function imejis_sizes() {
// null is the base canvas: a render with no `variation` at all.
return [
'og' => null,
'story' => defined('IMEJIS_VARIATION_ID') ? IMEJIS_VARIATION_ID : '',
];
}
add_action('rest_api_init', function () {
register_rest_route('imejis/v1', '/og-size', [
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => function (WP_REST_Request $request) {
$sizes = imejis_sizes();
$size = $request->get_param('size') ?: 'og';
if (!array_key_exists($size, $sizes)) {
return new WP_Error('imejis_unknown_size', 'Unknown size', ['status' => 404]);
}
$query = [];
if ($sizes[$size]) {
$query['variation'] = $sizes[$size];
}
$requested = $request->get_param('format') ?: '';
if (in_array($requested, IMEJIS_FORMATS, true)) {
$query['format'] = $requested;
}
$quality = (int) $request->get_param('quality');
if ($quality >= 1 && $quality <= 100) {
$query['quality'] = $quality;
}
$response = imejis_render('/v1/' . IMEJIS_DESIGN_ID, $query, [
'headline.text' => sanitize_text_field(
$request->get_param('title') ?: get_bloginfo('name')
),
]);
if (imejis_failed($response)) {
return new WP_Error('imejis_render_failed', 'Render failed', ['status' => 502]);
}
header('Content-Type: ' . (wp_remote_retrieve_header($response, 'content-type') ?: 'image/jpeg'));
header('Cache-Control: public, immutable, no-transform, max-age=31536000');
echo wp_remote_retrieve_body($response);
exit;
},
]);
});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.
// Ask for a stored URL instead of bytes. `hosted` is public and permanent;
// `signed` is private and expires (`expiresIn` is minutes).
add_action('rest_api_init', function () {
register_rest_route('imejis/v1', '/og-url', [
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => function (WP_REST_Request $request) {
$delivery = $request->get_param('private') ? 'signed' : 'hosted';
$query = ['delivery' => $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.
$requested = $request->get_param('format') ?: '';
if (in_array($requested, IMEJIS_FORMATS, true)) {
$query['format'] = $requested;
}
if ($delivery === 'signed') {
$query['expiresIn'] = 30;
}
$response = imejis_render('/v1/' . IMEJIS_DESIGN_ID, $query, [
'headline.text' => sanitize_text_field($request->get_param('title') ?: 'Shared card'),
]);
if (imejis_failed($response)) {
return new WP_Error('imejis_render_failed', 'Render failed', ['status' => 502]);
}
// { url, format, file: { size }, expiresAt (signed only) }
$stored = json_decode(wp_remote_retrieve_body($response), true);
return [
'url' => $stored['url'],
'format' => isset($stored['format']) ? $stored['format'] : null,
'expiresAt' => isset($stored['expiresAt']) ? $stored['expiresAt'] : null,
];
},
]);
});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.
// One POST renders many images and returns a URL for each, in the order sent —
// a row per post, or one per size.
add_action('rest_api_init', function () {
register_rest_route('imejis/v1', '/og-bulk', [
'methods' => 'POST',
'permission_callback' => '__return_true',
'callback' => function (WP_REST_Request $request) {
$rows = $request->get_param('rows') ?: [];
$response = imejis_render('/bulk', [], [
'designId' => IMEJIS_DESIGN_ID,
'delivery' => 'hosted',
'format' => 'png',
'data' => array_map(function ($row) {
return [
'headline.text' => sanitize_text_field($row['name']),
'subtitle.text' => sanitize_text_field(isset($row['role']) ? $row['role'] : ''),
];
}, $rows),
]);
if (imejis_failed($response)) {
return new WP_Error('imejis_render_failed', 'Render failed', ['status' => 502]);
}
$bulk = json_decode(wp_remote_retrieve_body($response), true);
return [
'summary' => $bulk['summary'],
'urls' => array_map(function ($result) {
return isset($result['url']) ? $result['url'] : null;
}, $bulk['results']),
];
},
]);
});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.
// Every single post gets its own card: og:image points at the REST route
// above, on your own domain, so the key stays server-side and crawlers get an
// absolute URL.
add_action('wp_head', function () {
if (! is_singular()) {
return;
}
$url = rest_url('imejis/v1/og') . '?title=' . rawurlencode(get_the_title());
printf('<meta property="og:image" content="%s" />' . "\n", esc_url($url));
echo '<meta name="twitter:card" content="summary_large_image" />' . "\n";
});
// And for a PUBLIC design, a shortcode that points an <img> straight at the
// render URL — no proxy. The key is in the markup then, so it has to be one
// you are happy to expose (hence its own constant). Keep the REST route
// whenever it must stay private: that is the right default.
add_shortcode('imejis_card', function ($atts) {
$atts = shortcode_atts(['title' => get_the_title()], $atts);
$url = imejis_base() . '/v1/' . IMEJIS_DESIGN_ID . '?' . http_build_query([
'dma-api-key' => defined('IMEJIS_PUBLIC_API_KEY') ? IMEJIS_PUBLIC_API_KEY : '',
'headline.text' => $atts['title'],
]);
return sprintf(
'<img src="%s" width="1200" height="630" alt="%s" />',
esc_url($url),
esc_attr('Card for ' . $atts['title'])
);
});Dynamic OG images. The last block is the whole link-preview pattern: rest_url() gives crawlers an absolute URL on your domain, and X, LinkedIn, Slack and iMessage then fetch a freshly rendered 1200×630 image carrying that post's real title. The title rides in the query string, so changing a post title changes the image URL. That is what makes the route's year-long immutable cache safe: a new title is a new URL, never a stale card.
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 as plain code are on the PHP page, and the no-code route is Zapier or Make. The REST callbacks echo and exit, which is what lets them return bytes rather than JSON, and it also means no REST response filter runs after them. The shortcode block puts a public key in the markup, so use one you are happy to expose. Every crawler fetch of an og:image is one render; 100 a month are free, then pricing.