AI Automation
Claude API Cost on Payload CMS: The Four Levers We Pull Before the Bill Hits €500/Month
Most AI features on Payload do not fail on quality — they fail on a bill that quietly triples in month three. Here is the token budget we set before writing a single hook, and the four levers we pull to hold it.

*Month one the AI feature is magic. Month three the invoice is louder than the feature ever was.*
The call we get around week ten is almost always the same. A Head of Content or Head of E-commerce forwards us their Anthropic invoice, the number has crossed €800 that month, and nobody on the team can explain what changed. The feature works. Editors like it. The bill is climbing anyway, and the person who greenlit the AI rollout now has to defend it to a CFO who wants a €/publish number by Friday.
We have wired Claude and OpenAI into enough Payload CMS and Medusa projects now to know that this is not a quality problem. The feature is fine. The architecture is not. Somebody shipped a prompt that pulls the entire article body plus six related documents into every publish hook, on the flagship model, in real-time, with no caching. It works. It just costs six times what it should.
This piece is the budget conversation we now have with every client before we write a single hook — the four levers we pull to hold the number, and the moment we tell an operator to stop scaling a workflow because the token bill has outrun the human review cost it was meant to replace.
The invoice pattern: why month three is when the panic starts
Month one, the archive has 400 articles and the AI feature runs on new publishes only. Bill: €40. Month two, someone runs a backfill across the archive. Bill: €280. Month three, the backfill is done but a second workflow — alt text, or internal linking, or translation — has landed on top, and nobody wired a cost dashboard. Bill: €740, and climbing.
The pattern is boring and it repeats. Nobody set a budget, so the ceiling is whatever the invoice happens to be. We now refuse to start an AI workflow on Payload or Medusa without three numbers agreed in writing: cost per publish, cost per SKU processed, and cost per support conversation (when a copilot is in scope). If we cannot name those three numbers before the first commit, we do not commit.
The budget conversation before the first hook
The math is not complicated. A typical Payload publish hook that generates SEO meta, an excerpt, and three internal link suggestions on Claude Sonnet 4 — with no caching, no batching, and a naive prompt that ships the full article body plus five related documents — lands around €0.04–€0.08 per publish. That sounds tiny. Multiply by 40 publishes per week and a backfill of 2,000 articles and you are at €120–€200 in month one, before anyone has said the word "scale".
The same feature, with the four levers pulled, lands around €0.005–€0.012 per publish. Same output quality on the workflows where we tested both. That is the delta we are chasing, and it is not clever — it is discipline applied in four specific places.
Lever 1: model tiering — Haiku and Flash where they belong
The Anthropic pricing page (current rates here) puts Claude Sonnet 4 at roughly 10x the input cost of Claude Haiku 3.5, and Gemini 1.5 Flash cheaper still. On paper this is obvious. In practice, most integrations we inherit run Sonnet on everything because it was the default in the SDK example and nobody went back to downgrade.
Our rule: Sonnet is for reasoning, structural editing, and anything that a senior editor would not sign off on if a junior wrote it. Haiku and Flash are for extraction, classification, reformatting, and short-form generation where the shape of the output is more important than judgment. Alt text, SEO meta titles, keyword extraction, category tagging, product attribute normalization — all Haiku. Long-form translation of nuanced editorial copy, brand-voice rewrites, and any workflow where a bad output goes live without human review — Sonnet, no negotiation.
// src/collections/Articles/hooks/enrichOnPublish.ts
import type { CollectionAfterChangeHook } from 'payload'
import Anthropic from '@anthropic-ai/sdk'
const anthropic = new Anthropic()
// Model tiering: cheap for extraction, expensive only for judgment.
const MODELS = {
extraction: 'claude-haiku-4-5', // ~$1 per 1M input tokens
judgment: 'claude-sonnet-4-5', // ~$3 per 1M input tokens
} as const
export const enrichOnPublish: CollectionAfterChangeHook = async ({
doc,
previousDoc,
operation,
req,
}) => {
if (operation !== 'update') return doc
if (doc._status !== 'published') return doc
if (previousDoc?._status === 'published') return doc // avoid re-runs
// Extraction workflows -> Haiku. No exceptions.
const [seoMeta, altTexts] = await Promise.all([
generateSeoMeta(doc, MODELS.extraction),
generateAltTexts(doc.hero, doc.blocks, MODELS.extraction),
])
// Brand-voice rewrite touches copy that ships to readers -> Sonnet.
const excerpt = await rewriteExcerpt(doc.body, MODELS.judgment)
await req.payload.update({
collection: 'articles',
id: doc.id,
data: { seoMeta, altTexts, excerpt },
context: { skipHooks: true },
})
return doc
}The two workflows where we refuse to downgrade: customer-facing copilot responses (Sonnet, always — a hallucinated refund policy costs more than the token savings) and brand-voice editorial rewrites (Sonnet, because Haiku's output reads flatter and editors will just rewrite it, which defeats the point).
Lever 2: prompt caching — the 90% discount most integrations leave on the table
Anthropic's prompt caching (docs here) discounts cached input tokens by roughly 90% on reads. On a Payload publish hook that ships a 4,000-token system prompt (brand voice guide, taxonomy, examples) plus the article body, this is not a nice-to-have — it is the difference between a €500/month bill and a €60/month bill.
The cache key shape we ship: everything that does not change between publishes goes in the cached block. Brand guide, few-shot examples, taxonomy, output schema — all cached. The article body itself goes uncached because it changes every call. The cache lives for 5 minutes on the default tier, which is fine for a publish burst but useless for a slow drip of publishes over a day.
// src/lib/ai/cached-system-prompt.ts
import Anthropic from '@anthropic-ai/sdk'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
const BRAND_GUIDE = readFileSync(join(process.cwd(), 'ai/brand-voice.md'), 'utf-8')
const TAXONOMY = readFileSync(join(process.cwd(), 'ai/taxonomy.json'), 'utf-8')
const EXAMPLES = readFileSync(join(process.cwd(), 'ai/few-shot.md'), 'utf-8')
export async function enrichWithCache(articleBody: string, model: string) {
return anthropic.messages.create({
model,
max_tokens: 800,
system: [
{
type: 'text',
text: `${BRAND_GUIDE}\n\n${TAXONOMY}\n\n${EXAMPLES}`,
// The cache_control marker is the 90% discount.
// Everything above this point is billed once, then read cheaply.
cache_control: { type: 'ephemeral' },
},
],
messages: [
{ role: 'user', content: `Enrich this article:\n\n${articleBody}` },
],
})
}The failure mode this pattern prevents: we inherited a Payload project where the same 3,800-token system prompt was being sent uncached on every hook fire, including hooks that fired twice per publish because of a nested `afterChange` on a related collection. The invoice was 11x what it should have been. We added cache_control, moved the second hook behind an idempotency check, and the next month's bill was €78 instead of €860. Same feature. Same quality. One config line and one hook cleanup.
Lever 3: batch vs real-time — when the Anthropic Batch API earns its 50%
The Anthropic Message Batches API (docs) is half-price on input and output, with a 24-hour SLA. The trade is latency: results arrive within 24 hours, usually much sooner, but you cannot depend on "sooner". For a publish hook where an editor is waiting on the SEO meta to appear in the sidebar, this is a bad fit. For a nightly backfill across 2,000 SKUs or a translation pass across an archive, it is the obvious choice and most teams ignore it.
Our default: any workflow where the human is not in the loop within the next hour goes on the Batch API. We queue jobs with BullMQ on Redis, submit batches of 500–1000 requests, poll for completion, and write results back through the Payload Local API with `context.skipHooks` set so we do not trigger a second hook cascade.
// src/jobs/enrichCatalog.ts — nightly SKU enrichment on the Batch API
import { Worker } from 'bullmq'
import Anthropic from '@anthropic-ai/sdk'
import { getPayload } from 'payload'
import config from '@payload-config'
const anthropic = new Anthropic()
new Worker('catalog-enrichment', async (job) => {
const { skuIds } = job.data as { skuIds: string[] }
const payload = await getPayload({ config })
const products = await payload.find({
collection: 'products',
where: { id: { in: skuIds } },
// Lever 4 preview: only pull the fields we actually prompt with.
select: { title: true, rawSpecs: true, category: true },
limit: skuIds.length,
})
const batch = await anthropic.messages.batches.create({
requests: products.docs.map((p) => ({
custom_id: String(p.id),
params: {
model: 'claude-haiku-4-5',
max_tokens: 400,
system: [{
type: 'text',
text: CATALOG_SYSTEM_PROMPT,
cache_control: { type: 'ephemeral' },
}],
messages: [{
role: 'user',
content: `SKU: ${p.title}\nSpecs: ${p.rawSpecs}\nCategory: ${p.category}`,
}],
},
})),
})
return { batchId: batch.id, count: products.docs.length }
}, { connection: { host: process.env.REDIS_HOST! } })On a 2,000-SKU catalog enrichment pass with Haiku, real-time streaming lands around €14–€18. Same job on the Batch API with prompt caching: €4–€6. Same output. The catalog does not care whether it took 40 minutes or 4 hours to enrich overnight.
Lever 4: input discipline — stop shipping 8k tokens of irrelevant relations
This is the lever that requires the least code and gets pulled the least often. Payload's `find` and `findByID` default to returning relations two or three levels deep. If your article has a `relatedProducts` field and each product has a `category` and each category has `parentCategory`, one naive query pulls 6–12kb of JSON into memory. If that JSON ends up serialized into a prompt — which happens more often than we would like — you are billing tokens for data the model never needed.
The fix is `select` and `depth: 0` on every AI hook, always. We pull the minimum viable payload for the prompt, cast it explicitly, and never trust the model to "ignore the fields it does not need" — because you are still paying for them.
// The wrong way — costs 3-6x what it should.
const article = await req.payload.findByID({
collection: 'articles',
id: doc.id,
// depth defaults to 2 — pulls relations, and relations of relations.
})
// article now weighs 8-12kb and half of it never enters the prompt.
// The right way — pay for what you use.
const article = await req.payload.findByID({
collection: 'articles',
id: doc.id,
depth: 0,
select: {
title: true,
body: true,
category: true, // ID only at depth 0, which is all we need
},
})A worked example: 2,000-SKU catalog enrichment
One shape we ship regularly: a Medusa catalog of 2,000 SKUs, each needing a description rewrite, three attribute extractions (material, weight band, care instructions), and an SEO title. Four cost lines, one final number we hold to.
Baseline (Sonnet, real-time, no cache, full relations): ~€0.09 per SKU → €180 for the pass
+ Model tiering (Haiku for extraction, Sonnet only for description): ~€0.028 per SKU → €56
+ Prompt caching on the system prompt (brand guide + taxonomy): ~€0.011 per SKU → €22
+ Batch API for the overnight pass: ~€0.006 per SKU → €12
+ Input discipline (`select` + `depth: 0`): ~€0.004 per SKU → €8
€180 to €8 for the same catalog pass, same output quality. The number we hold clients to on this shape of workflow is €3–€8 per 1,000 SKUs enriched, all-in, on Haiku with caching and batching. If we cannot hit that, something is wrong with the prompt shape or the input selection, not with the model choice.
The workflow we tell operators to stop scaling
Not every AI workflow deserves to grow. The test we apply: if the token bill for a workflow crosses roughly 60% of the human review cost it was meant to replace, we stop scaling and go back to the drawing board. At that point either the human review is cheap (and the AI is a bad trade), or the AI is expensive because the prompt is wrong (and needs the four levers before it scales).
The workflow we most often kill: AI-generated long-form editorial rewrites on Sonnet, at scale, with mandatory human review afterwards. If a senior editor is going to rewrite 40% of the output anyway, the token cost plus the editor cost is higher than the editor cost alone. That workflow belongs in Haiku for a first draft, or not at all.
The dashboard we wire on day one
The last piece is the one that keeps the invoice from ever being a surprise again. Every AI hook we ship writes to a `ai_usage` table in Postgres: hook name, model, input tokens, output tokens, cached tokens, cost in cents, timestamp, and the ID of the document that triggered it. A tiny Payload custom view aggregates this by day, hook, and model so the operator can answer "what did we spend on alt text last week" without opening the Anthropic console.
-- Migration: ai_usage table lives next to the Payload schema
CREATE TABLE ai_usage (
id BIGSERIAL PRIMARY KEY,
hook_name TEXT NOT NULL,
model TEXT NOT NULL,
collection TEXT NOT NULL,
document_id TEXT,
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
cached_tokens INTEGER NOT NULL DEFAULT 0,
cost_cents INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Partial index for the two queries the dashboard runs constantly:
-- "spend this month by hook" and "spend this month by model".
CREATE INDEX ai_usage_month_hook ON ai_usage (hook_name, created_at DESC);
CREATE INDEX ai_usage_month_model ON ai_usage (model, created_at DESC);The dashboard is 200 lines of React inside a Payload custom view. It answers three questions: what did we spend yesterday, which hook is the biggest line item, and is any single hook trending up week over week. That is the whole feature. It has caught a runaway backfill on three separate projects before the monthly invoice arrived.
If the workflow you are budgeting is on the commerce side — catalog enrichment, PDP generation, attribute extraction across thousands of SKUs — See how we ship Medusa storefronts with AI catalog ops and the token budget baked in from day one.
If your Claude or OpenAI invoice has climbed past €500/month and nobody on the team can name the €/publish number, Send us your current stack and monthly AI spend — we will tell you which of the four levers is missing before we quote anything.
What we ship by default on every Payload project that has an AI feature in scope: the four levers wired from commit one, the `ai_usage` table and the custom dashboard view, and a written budget in the project brief with three denominators the CFO can defend. The features that survive month three are not the smartest ones — they are the ones where somebody set the number before the first hook was written.
// After the call
Questions operators ask next
How do I estimate a monthly Claude budget before we start building?
Pick the three denominators that matter for your workflow: cost per publish, cost per SKU, or cost per support conversation. Multiply by realistic monthly volume. For a typical Payload editorial workflow with the four levers pulled, budget €0.005–€0.012 per publish on Haiku with caching, or €0.03–€0.08 per publish on Sonnet without. If you cannot name one of those three denominators, you are not ready to ship the feature.
Does prompt caching work with the Payload Local API and afterChange hooks?
Yes — caching is an Anthropic API feature and is transport-agnostic on your side. What matters is that the cached block (system prompt, taxonomy, few-shot examples) is byte-identical between calls and that calls arrive within the 5-minute ephemeral cache window. On a publish burst inside a Payload afterChange hook, the second and third publishes hit the cache. On slow drips (one publish per hour), the cache expires and you pay full price — batch those workflows instead.
When should we use the Anthropic Batch API instead of streaming?
Any workflow where the human is not waiting in the next hour. Nightly catalog enrichment, archive translation, backfills, bulk alt text generation — all Batch API. Half-price on input and output for a 24-hour SLA. Editor-facing publish hooks stay real-time because a 30-second wait for SEO meta in the sidebar is fine but a 4-hour wait is not.
Is Haiku actually good enough for production editorial workflows?
For extraction, classification, and short-form structured output — yes, and it is not close. Alt text, SEO titles, keyword tagging, attribute normalization, category assignment all ship on Haiku in production. For long-form brand-voice rewrites and customer-facing copilot responses we still default to Sonnet — Haiku's judgment on nuanced editorial voice is noticeably flatter and editors rewrite it, which erases the savings.
What is the cheapest way to backfill AI enrichment across a 5,000-article archive?
Haiku on the Batch API with prompt caching on the system prompt and `select` + `depth: 0` on the Payload query. On our shape of workload this lands around €15–€30 for the full pass. Run it overnight, poll the batch endpoint every 5 minutes, write results back through the Local API with `context.skipHooks` set so you do not fan out into a second cascade of afterChange hooks.
How do we catch a runaway AI cost before the monthly invoice arrives?
Log every hook call to a Postgres `ai_usage` table with tokens, cost, and hook name, then wire a Payload custom view that shows spend-by-hook and spend-by-model for the current month. Set a Slack alert on any hook whose 7-day spend crosses 1.5x its 30-day average. This has caught runaway backfills on three separate projects for us before the Anthropic console did.
Pull quote
The AI features that survive month three are not the smartest ones. They are the ones where somebody set a €/publish number before the first hook was written.