Product Optimizations API

Last updated: October 9, 2026

ENTITLEMENT REQUIRED

Generate AI-optimized product descriptions from review data and publish them to Shopify. The optimization workflow is: start → poll → edit (optional) → approve → publish.

Availability: all endpoints in this section require the product_pages entitlement. Without it, write calls return 403 entitlement_required and reads return 200 with content: null. Better and Studio include it. On legacy plans (no longer sold), it is available on Growth and above, and on legacy Pro and Enterprise; stores on the legacy Starter plan do not have it. The Free plan does not include it, and neither does a cancelled store (cancelling returns the store to Free); those calls return 403 entitlement_required, and every LLM-backed endpoint returns 402 subscription_required. See Pricing.

Note: Product IDs in these endpoints are internal BetterReviews IDs, not Shopify IDs. Use GET /products/lookup/:platform_id to convert a Shopify product ID first.

Start Optimization

POST /api/v1/products/:product_id/optimize

Starts an async AI optimization job. Returns immediately with status pending.

Request

{
  "feedback": "Focus on fabric quality and comfort",
  "blocks_to_optimize": ["features", "seo_description"],
  "featured_review_id": 561027
}
FieldTypeRequiredDescription
feedbackstringNoInstructions for the AI (tone, focus areas, keywords) for this run only — not remembered for later regenerations; standing rules belong in the store's ppo_content setting. Max 1,000 characters; markup, instruction-like phrasing and contact details are rejected
blocks_to_optimizestring[]NoSpecific sections to optimize. Canonical spelling; blocksToOptimize is accepted as an alias and ignored when both are sent
featured_review_idintegerNoReview id to use for the top_review widget instead of automatic selection

featured_review_id must name a review of this product that is approved, not hidden, rated 4★ or better and at least 15 words long. Anything else leaves top_review empty with reason: "requested_review_invalid" on the verification report — the API never says which rule failed.

422 invalid_prompt_text — feedback broke the prompt-text rules or exceeded 1,000 characters. 422 invalid_featured_review_id — featured_review_id was not a positive integer, or blocks_to_optimize was set and did not include top_review (it is rejected, never silently ignored). Nothing is generated and nothing is charged in either case.

422 widget_disabled — blocks_to_optimize named a block the store has turned off in ppo_content.enabled_widgets, or no enabled block was left. Nothing is generated or charged. With blocks_to_optimize omitted, only the enabled blocks count toward the regeneration cap.

An empty top_review is reported as no_candidates when nothing in the retrieved set cleared those rules. Either way the widget is omitted from the published page rather than rendered blank.

Response (202)

{
  "id": "uuid",
  "status": "pending",
  "version": 1,
  "created_at": "2026-03-27T10:00:00Z"
}

If a draft already exists for the product, this counts as a whole-page regeneration — same five-free-per-block cap and same 402 insufficient_credits as Regenerate. A product with no draft (or whose latest optimization is published) is a first generation: free and uncounted.

Re-optimizing a draft with blocks_to_optimize regenerates only the listed blocks and carries the others over from the draft it replaces, the same as Regenerate. (Before October 9, 2026 the new draft held only the listed blocks.)

429 unpublished_optimization_limit — you've reached the per-period maximum of products optimized-but-not-published. Publish a draft to unlock the next, or wait for the next billing period. Re-optimizing a product you've already drafted this period is exempt.

409 optimization_in_progress — returned when the product's current optimization is still pending or in_progress. Poll the optimization until it reaches draft or failed, then retry.


Get Current Optimization

GET /api/v1/products/:product_id/optimization

Poll this endpoint after starting an optimization to check status and get results.

Response (200)

{
  "id": "uuid",
  "version": 3,
  "status": "draft",
  "content": {
    "description": "...",
    "features": ["..."],
    "seo_title": "...",
    "seo_description": "..."
  },
  "created_at": "2026-03-27T10:00:00Z",
  "published_at": null,
  "error_message": null
}

List All Versions

GET /api/v1/products/:product_id/optimizations?limit=10&offset=0

Returns paginated list of all optimization versions for a product.


Edit Optimization

PATCH /api/v1/products/:product_id/optimization

Request

{
  "content": {
    "features": { "features": ["Grips on wet rock", "..."] }
  }
}

content accepts only the five widget keys: features, top_review, reviews_summary, faq, seo_description. Anything else — including _metadata and verification_status — is rejected with 422 invalid_widget_key.

Edited text has not been fact-checked, so an edit re-runs the fact check over the widgets you changed. A widget you resubmit byte-identical is skipped entirely — not re-checked, and not counted against the rate limit.

Edits are fact-checked, not style-checked. The content rules (banned topics and words, one drawback per page) apply only to generated text. The fact check does apply: a quote must match a source review exactly, so a near-quote you typed is removed as inexact_quote. Edit and re-check responses carry removed_on_verify, a per-widget summary of what the check removed and why — for example [{"widget": "features", "count": 2, "reasons": ["inexact_quote", "unsupported_claim"]}]. The removed sentences are in verification.widgets[key].removed.

A draft generated before the fact check shipped carries no report and no record of the reviews it was written from. Editing one saves the text and leaves it unchecked (verification: null); it cannot be checked after the fact, so regenerate it to publish.

200 — the check finished inline (up to two changed widgets). The body carries the updated content and the verification report.

202 — more than two widgets changed, or the checker was briefly unreachable. The content is saved and the check finishes in the background; verification_status is pending and publishing is blocked until it resolves.

422 invalid_content — content carried a fractional number. Widget content may only use whole numbers; a fractional one cannot be hashed identically on both sides of the fact check.

409 optimization_changed — the draft changed while the check was running. Reload it and re-apply your edit.


Re-check

POST /api/v1/products/:product_id/optimization/recheck

Re-runs the fact check over every widget whose status is unverified or pending. Widgets that already passed are left exactly as they are — their report and their text are untouched.

Same response contract as an edit: 200 when two or fewer widgets needed checking, 202 when more did and the check finishes in the background. Rate limited with the same 60/hour/store budget as edits that change something.

422 legacy_draft_regenerate — the draft predates the fact check and carries no record of the reviews it was written from, so there is nothing to check it against. Regenerate it.

422 invalid_content — the stored draft holds a fractional number, which cannot be hashed identically on both sides of the check. Regenerate it.

A draft whose source reviews no longer resolve (deleted, or erased under GDPR) comes back unverified with reason: "no_sources" and stays that way however often it is re-checked — regenerate it. A draft that never had source reviews is a different case: it is checked against the product spec alone and can pass.


Regenerate

POST /api/v1/products/:product_id/optimization/regenerate

Creates a new version. Accepts same parameters as Start (feedback, blocks_to_optimize, featured_review_id), with the same 422 invalid_prompt_text and 422 invalid_featured_review_id rules.

When blocks_to_optimize is set, the widgets you don't list are carried over from the previous version — only the listed blocks are regenerated.

Accepted blocks_to_optimize values: features, top_review, reviews_summary, faq, seo_description. Hyphenated spellings (seo-description) are accepted and normalized, and blocksToOptimize is accepted as an alias for the field name itself (snake_case wins when both are sent). The reviews-summary sub-sections summary, best_features, quality_feedback and top_quotes all resolve to the reviews_summary block and share its counter. Unrecognized names are ignored as long as at least one recognized name remains. Omit the field to regenerate the whole page.

422 invalid_blocks — blocks_to_optimize was not a list, or named no recognized block. Nothing is regenerated and nothing is charged; it is never silently treated as a whole-page regeneration.

422 widget_disabled — blocks_to_optimize named a block the store has turned off in ppo_content.enabled_widgets. Regenerating the whole page charges only the enabled blocks.

A different top review. Regenerating top_review with feedback takes the review the product's previous version featured off the table — the current draft's, or after a publish the live page's — so the new draft features a different one when another eligible review exists.

Cost: the first generation of a draft is free and is not counted — that includes regenerating a product that has no draft yet, and the first regenerate after a publish (which starts a new draft). After that each block gets 5 free regenerations per draft; regenerating the whole page counts one against every block. Past 5, a regenerated block costs 1 AI credit (whole page: up to 5) from the same pool as AI-guided reviews. The counters reset when you publish the product.

Every optimization response carries the current counters for the product's unpublished draft: regeneration_counts (regenerations used this draft, keyed by block), free_remaining (max(0, 5 - used) per block) and next_regeneration_cost (AI credits a whole-page regenerate would cost right now).

{
  "regeneration_counts": {"features": 5, "top_review": 1, "reviews_summary": 0, "faq": 0, "seo_description": 0},
  "free_remaining": {"features": 0, "top_review": 4, "reviews_summary": 5, "faq": 5, "seo_description": 5},
  "next_regeneration_cost": 1
}

402 insufficient_credits — returned on regenerate when the store has no AI credits left to pay for an over-cap block. This is a different string from the 402 credit_limit_reached returned on publish — key on detail, not the status. Enable auto-recharge or upgrade, then retry.

409 optimization_in_progress — returned when the product's current optimization is still pending or in_progress. Poll the optimization until it reaches draft or failed, then retry.


Mark a generation as low quality

POST /api/v1/products/:product_id/optimization/feedback

Tells us one block of the product's latest optimization is bad. The optimization is resolved server-side — the body names a block, never a row id.

{"widget": "features", "reason": "hallucinated", "note": "invented a care instruction"}

widget (required) is one of features, top_review, reviews_summary, faq, seo_description — hyphenated spellings and reviews-summary sub-sections normalize the same way as blocks_to_optimize. reason (required) is one of hallucinated, off_brand, too_short, other. note (optional) is free text, max 500 characters after sanitization; HTML, instruction-like phrasing and contact details are rejected.

{
  "flagged": true,
  "refunded": true,
  "regeneration_counts": {"features": 2, "top_review": 0, "reviews_summary": 0, "faq": 0, "seo_description": 0},
  "free_remaining": {"features": 3, "top_review": 5, "reviews_summary": 5, "faq": 5, "seo_description": 5},
  "next_regeneration_cost": 0
}

The flag is always recorded. The refund is not. refunded is true only when our own fact check agrees with you — that block's verification report came back as something other than verified, or it had sentences stripped out of it. A block the check passed cleanly is recorded with refunded: false. A block generated before the fact check shipped has no report, so it never refunds.

A refund voids the most recent regeneration of that block: the free slot comes back and any AI credit it cost drops out of your usage for the period. A voided charge does not refund an auto-recharge block you have already purchased — that money is spent with Shopify.

Ceiling: at most 20 refunded flags per store per billing period. Flags past the ceiling are still recorded, with refunded: false.

Idempotent: flagging the same block of the same optimization twice stores nothing new and never refunds twice — the second call returns the first call's refunded. Regenerating that block produces a new optimization, which can be flagged on its own.

Inline on regenerate. To flag what you're replacing in the same call, send previous_quality: "low" plus reason (and optionally note) to the regenerate endpoint. The flag is applied to every block that call regenerates, before the new regeneration is priced — so a refunded slot is free again for it.

422 invalid_prompt_text — the note contains markup, instruction-like phrasing or contact details, or exceeds 500 characters. Returned by both this endpoint and the inline form on regenerate. invalid_widget / invalid_reason are returned for a block or reason outside the accepted set.

404 — the product has no optimization at all (or belongs to another store). There is nothing to flag; start an optimization first.

500 flag_write_failed — the flag could not be written. Nothing was recorded and nothing was refunded; retry.

Rate limit: 20 requests per hour per store.


Approve

POST /api/v1/products/:product_id/optimization/approve

Marks the optimization as approved and ready to publish.


Publish to Shopify

POST /api/v1/products/:product_id/optimization/publish

Writes the optimization content to the Shopify product metafield. The storefront updates with the new content.

Cost: the first generation of a draft is free, and so are the first five regenerations of each block (see Regenerate). Publishing draws 5 AI credits per distinct product, per billing period (re-publishing the same product in the same period is free), on every store.

Rate limit: 30 requests/hour/store (shared with the MCP publish_optimization tool) → 429.

402 subscription_required — the store's subscription is not active (pending, cancelled or frozen). Publishing draws credits and writes live storefront content, so it needs an active subscription like the generation endpoints do. This body uses error, not detail.

402 credit_limit_reached — returned on publish when the store's credit pool for the period is exhausted. Enable auto-recharge and extra credits bill at the plan's block rate; with auto-recharge active, publish proceeds and bills the overage.

422 unverified_content — the draft has not passed the fact check. The body carries verification_status and can_recheck. With can_recheck: true a check is still running or the text changed after it was checked — wait for it, or call Re-check. With can_recheck: false the draft predates the fact check and carries no report at all; regenerate it to publish.

A widget whose status is insufficient_evidence does not block publishing — it is left off the live page. So is a block the store has turned off in ppo_content.enabled_widgets, which does not block publishing even if it was never checked. The stored draft keeps both. The publish response lists what was left off:

"omitted_widgets": [
  {"widget": "faq", "reason": "insufficient_evidence"},
  {"widget": "seo_description", "reason": "disabled"}
]

A draft generated before the fact check shipped (no report) omits nothing on evidence grounds.


Fact check

Every generated widget is checked against the reviews it was written from and the merchant's own product spec before it can be published. Quoted text must appear exactly in a source review — a near-match (reworded or re-spelled) is removed as inexact_quote, including in merchant edits; each sentence's factual claims must trace to a review or to the spec, and measurable attributes (dimensions, weight, materials, care instructions, counts, prices) may come only from the spec. Sentences that fail are removed before the draft is saved.

The result is stored server-side as verification on the optimization and is never writable through the API.

source_review_ids is the set the check was run against: the reviews the generator actually read, plus the reviews behind any verbatim quote the draft ships (the top review and the quote bank). The top-level judge_runs_ok is the lowest of the per-widget counts, so a 0 means at least one widget the checker never scored — it is diagnostic only, and publishing is gated on status.

Widget statusMeaningBlocks publish
verifiedEvery claim traced to a review or the specNo
insufficient_evidencePassed the check but fell under the widget's content floor. Left off the live page on publish (omitted_widgets)No
pendingA widget was edited or queued for a re-check and the answer has not come backYes
unverifiedThe check failed or was unavailable (no evidence, checker down, unreadable widget shape), or the text changed after it was checkedYes

Content rules

Generated text follows the store's content policy, ppo_content in review settings:

  • Banned topics — on by default for every store: price and value for money, promotions and discounts, shipping/delivery/stock, customer service and returns, and other retailers. Generated sentences, FAQ items and quotes that touch one are dropped. Opt out per topic by removing it from banned_topics.
  • Banned terms — up to 25 words or short phrases the copy must never use, matched as whole words plus simple plurals and verb endings (order also matches "in order to").
  • Enabled widgets — the blocks the store publishes. Disabled blocks are not charged on regenerate and are left off the live page.

Other rules applied to every generation:

  • One drawback per page. At most one sentence or FAQ item that raises a drawback, limitation or caveat survives across the page.
  • Evidence floors. With 1–2 approved reviews, the reviews summary and FAQ are not generated (features and the top review still are). The page shows up to 5 items per list (features, highlights, FAQ). Products with fewer than 10 approved reviews ask for up to 4 FAQ questions and are never padded, and with fewer than 10 the copy never claims "many" or "most" customers. Exact review counts are never stated.
  • Names. Reviewer names appear only as "First L." (for example "Linda G."), resolved from the review at publish time, and never inside generated prose.

These rules act on the next generation only — existing drafts and live pages do not change until you regenerate them.


Discard

DELETE /api/v1/products/:product_id/optimization


Optimization Statuses

StatusMeaning
pendingProcessing (async job running)
draftGenerated, ready for review
approvedMerchant approved, ready to publish
publishedLive on Shopify storefront
failedGeneration failed (check error_message)
discardedMerchant discarded