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.

The Postgres schema
1CREATE TYPE generation_status AS ENUM (2 'pending', 'in_progress', 'completed', 'failed'3);45CREATE 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 NULL16);
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.

Submitting the job
1import { fal } from "@fal-ai/client";2import { pool } from "./db.js";34export async function submit(editorId, prompt) {5 const s = await fal.queue.submit("fal-ai/flux/dev", {6 // or fal-ai/gpt-image-2 once available7 input: { prompt, image_size: "landscape_16_9", num_inference_steps: 28 },8 webhookUrl: "https://cms.example.com/webhooks/fal",9 });1011 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 );1617 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
1import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";2import { pool } from "./db.js";3import { writeCmsEntry } from "./cms.js";45const s3 = new S3Client({ region: "auto" });67app.post("/webhooks/fal", async (req, res) => {8 const { request_id, status, payload, error } = req.body;910 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");1415 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 }2223 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 }));2930 const permanent = `https://cdn.example.com/${key}`;31 const cmsId = await writeCmsEntry(request_id, permanent);3233 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]);3738 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.