Strapi: OG images on publish
Strapi's lifecycle hooks run in-process, no webhook plumbing needed. On publish, call the OGImagen API and store the permanent URL in a text field. Your frontend just reads it.
1. Add the field
In the content-type builder, add a text field named ogImageUrl to your post/article type (and optionally a boolean aiOgImage for the AI opt-in).
2. The lifecycle hook
// src/api/article/content-types/article/lifecycles.ts
import { createHash } from 'crypto';
const OGIMAGEN = 'https://ogimagen.com/api/v1';
const headers = {
Authorization: `Bearer ${process.env.OGIMAGEN_API_KEY}`,
'Content-Type': 'application/json',
};
export default {
async afterUpdate(event) {
const { result } = event;
// Fires on every save; act only on published entries without an image.
if (!result.publishedAt || result.ogImageUrl) return;
if (!result.aiOgImage) {
// Default: deterministic template, 0 credits, synchronous.
const res = await fetch(`${OGIMAGEN}/templates`, {
method: 'POST',
headers,
body: JSON.stringify({
template: 'blog',
title: result.title,
siteName: 'yoursite.com',
brandKitId: process.env.OGIMAGEN_BRAND_KIT_ID,
}),
});
const { url } = await res.json();
await strapi.documents('api::article.article').update({
documentId: result.documentId,
data: { ogImageUrl: url },
});
return;
}
// Hero path: AI, async. The webhook below writes the URL back.
await fetch(`${OGIMAGEN}/generations`, {
method: 'POST',
headers,
body: JSON.stringify({
title: result.title,
displayText: result.title,
quality: 'flash',
variants: 1,
idempotencyKey: createHash('sha256')
.update(`${result.slug}:${result.title}`)
.digest('hex'),
webhookUrl: `https://your-strapi.com/api/og-done?documentId=${result.documentId}`,
webhookSecret: process.env.OGIMAGEN_WEBHOOK_SECRET,
}),
});
},
};3. Receive the AI result (custom route)
// src/api/og-done/routes/og-done.ts
export default {
routes: [{ method: 'POST', path: '/og-done', handler: 'og-done.receive', config: { auth: false } }],
};
// src/api/og-done/controllers/og-done.ts
import { createHmac, timingSafeEqual } from 'crypto';
export default {
async receive(ctx) {
const body = ctx.request.body[Symbol.for('unparsedBody')] ?? JSON.stringify(ctx.request.body);
const ts = ctx.request.header['x-ogimagen-timestamp'] ?? '';
const sig = ctx.request.header['x-ogimagen-signature'] ?? '';
const expected = 'sha256=' + createHmac('sha256', process.env.OGIMAGEN_WEBHOOK_SECRET)
.update(`${ts}.${body}`).digest('hex');
if (sig.length !== expected.length ||
!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return ctx.unauthorized('bad signature');
}
const { generation } = JSON.parse(body);
if (generation.status === 'done') {
await strapi.documents('api::article.article').update({
documentId: ctx.query.documentId,
data: { ogImageUrl: generation.urls.og },
});
}
ctx.body = { ok: true };
},
};(Strapi parses JSON bodies by default; enable includeUnparsed in the body middleware to verify the signature over the raw payload.)
4. Meta tags in the frontend
<meta property="og:image" content={article.ogImageUrl} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />Notes
- The
result.ogImageUrlguard plus theidempotencyKeymeans repeated saves and republish storms never double-charge. - Self-hosting Strapi behind a firewall? The template path works fully outbound (no inbound webhook needed), only the AI path needs a reachable
webhookUrl, or swap it for pollingGET /api/v1/generations/:id.
Full parameter reference in the API docs. Other platforms: all recipes.