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.ogImageUrl guard plus the idempotencyKey means 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 polling GET /api/v1/generations/:id.

Full parameter reference in the API docs. Other platforms: all recipes.