Integration3 min read

Integrating GPT Image 2 into a Headless CMS Pipeline

If you run a headless CMS, you know the painful version of image generation: editors click a button, wait 40 seconds, and the page goes white when the upstream URL expires three hours later. This post shows the minimum production setup to avoid both.


If you run a headless CMS, you know the painful version of image generation: editors click a button, wait 40 seconds, and the page goes white when the upstream URL expires three hours later. This post shows the minimum production setup to avoid both. One Postgres table, one object store bucket, one webhook endpoint, idempotency discipline. Works for Sanity, Contentful, Strapi. Model reference below is Flux dev at $0.003 per image, with the comment for swapping to GPT Image 2 when ready.

The three pieces

Piece 1: generations table. One row per job, indexed by request_id. Idempotency lives here.

Piece 2: your own object storage. S3, R2, Supabase Storage. fal output URLs are time-limited. Every image in your CMS must have been copied first.

Piece 3: a webhook endpoint. fal calls this when a job finishes. Copy output, write CMS row, mark complete.

Three-piece architecture
Three-piece architecture

The Postgres schema

SQL
1CREATE TYPE generation_status AS ENUM (
2 'pending', 'in_progress', 'completed', 'failed'
3);
4
5CREATE TABLE generations (
6 id BIGSERIAL PRIMARY KEY,
7 request_id TEXT UNIQUE NOT NULL,
8 inputs JSONB NOT NULL,
9 status generation_status NOT NULL DEFAULT 'pending',
10 output_url TEXT,
11 cms_entry_id TEXT,
12 error_message TEXT,
13 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
14 finished_at TIMESTAMPTZ,
15 editor_id TEXT NOT NULL
16);

The UNIQUE constraint on request_id is the backbone of idempotency. fal sometimes retries deliveries; if two land in the same second, one hits the constraint and you ignore the dupe. inputs stores the full prompt for auditing.

Webhook sequence
Webhook sequence

Submitting the job

JAVASCRIPT
1import { fal } from "@fal-ai/client";
2import { pool } from "./db.js";
3
4export async function submit(editorId, prompt) {
5 const s = await fal.queue.submit("fal-ai/flux/dev", {
6 // or fal-ai/gpt-image-2 once available
7 input: { prompt, image_size: "landscape_16_9", num_inference_steps: 28 },
8 webhookUrl: "https://cms.example.com/webhooks/fal",
9 });
10
11 await pool.query(
12 `INSERT INTO generations (request_id, inputs, status, editor_id)
13 VALUES ($1, $2, 'in_progress', $3)`,
14 [s.request_id, { prompt }, editorId],
15 );
16
17 return s.request_id;
18}

Write to the database right after submission. If the webhook fires first, return 202 and let fal retry.

The webhook handler

JAVASCRIPT
1import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
2import { pool } from "./db.js";
3import { writeCmsEntry } from "./cms.js";
4
5const s3 = new S3Client({ region: "auto" });
6
7app.post("/webhooks/fal", async (req, res) => {
8 const { request_id, status, payload, error } = req.body;
9
10 const row = await pool.query(
11 "SELECT status FROM generations WHERE request_id=$1", [request_id]);
12 if (row.rows[0]?.status === "completed")
13 return res.status(200).send("already processed");
14
15 if (status === "ERROR" || error) {
16 await pool.query(
17 `UPDATE generations SET status='failed',
18 error_message=$2, finished_at=NOW() WHERE request_id=$1`,
19 [request_id, error || "unknown"]);
20 return res.status(200).send("failed");
21 }
22
23 const bytes = await fetch(payload.images[0].url).then(r => r.arrayBuffer());
24 const key = `images/${request_id}.jpg`;
25 await s3.send(new PutObjectCommand({
26 Bucket: "cms-images", Key: key,
27 Body: Buffer.from(bytes), ContentType: "image/jpeg",
28 }));
29
30 const permanent = `https://cdn.example.com/${key}`;
31 const cmsId = await writeCmsEntry(request_id, permanent);
32
33 await pool.query(
34 `UPDATE generations SET status='completed', output_url=$2,
35 cms_entry_id=$3, finished_at=NOW() WHERE request_id=$1`,
36 [request_id, permanent, cmsId]);
37
38 res.status(200).send("ok");
39});

Order matters. Copy to your bucket first, write the CMS row, then mark complete. If the bucket copy fails, the CMS is not polluted with a dead link.

Idempotency and cost

The most common failure is the double webhook. fal occasionally redelivers the same request_id within seconds. The "already processed" check is load-bearing.

GPT Image 1.5 runs $0.005 to $0.20 per image. Image 2 pricing is unannounced; assume the same band. Storage is trivial: 100k images at 300 KB is 30 GB, under $1 per month on R2. Show a status badge on every CMS entry: generating, ready, failed. Editors stop double-clicking once they see the state.


Also reading