# Going Stellar > A small product and performance team in Brașov, Romania. We publish applications on the Shopify App Store, the Google Workspace Marketplace and Atlassian Forge, and we run paid acquisition, SEO and profitability data for a group of e-commerce stores. GOING STELLAR S.R.L. · VAT RO47020390 · Str. Zizinului 667, Tărlungeni 507220, Brașov, Romania · gheorghe.beschea@overheat.agency Applications verified 11 October 2026; operating figures 31 August 2026. Every figure below names the system it came from; the long file has the queries. ## What we publish - Shopify App Store: 20 applications, live and installable. - Google Workspace Marketplace: 15 add-ons, live and installable. - Atlassian Forge: 25 apps built for Jira and Confluence; 4 of them are live on the Atlassian Marketplace (vendor page: https://marketplace.atlassian.com/vendors/12665203), the other 21 are not yet listed. - monday.com: 1 board view built and submitted; the first review (28 September 2026) sent it back, so it is not listed. - Total built across four marketplaces: 61, of which 39 are live. ## Applications ### Shopify App Store — live - **Order Hub** (https://apps.shopify.com/order-hub): Our own fulfilment platform, as a Shopify app: ship with your own courier contracts, validate every address before the label prints, and cut refused parcels. The same platform runs the whole fleet of stores we operate. - **Cite Me** (https://apps.shopify.com/cite-me): Audits whether AI crawlers can read your store, then adds citable questions, answers and structured data so AI search can quote your products. - **Basket Boost** (https://apps.shopify.com/basket-boost): Mines what customers really buy together from your order history and shows accurate bought-together picks on the product page. No AI guessing. - **Parcel Pulse** (https://apps.shopify.com/parcel-pulse): Catches duplicate orders and broken shipping addresses in your unshipped queue, before the label prints and the parcel gets refused. - **COD Flex** (https://apps.shopify.com/cod-flex): Scores every cash-on-delivery order for refusal risk before you ship it, tags the risky ones, and learns from the parcels that came back. - **Winback IQ** (https://apps.shopify.com/winback-iq): Scores customers on recency, frequency and value from your own orders, and ranks who is slipping away by the revenue actually at risk. - **Price Sentry** (https://apps.shopify.com/price-sentry): Tracks competitors’ public prices, scores your pricing health, and shows where you are being undercut over a 30-day trend. - **Review Lift** (https://apps.shopify.com/review-lift): Shows which of your best-selling products still have no reviews, ranked by real sales, and drafts grounded replies to the low-rated ones. - **Page Proof** (https://apps.shopify.com/page-proof): Scores the conversion gaps on your product and landing pages, then applies grounded, reversible copy fixes. Reads public markup only. - **Pixel Proof** (https://apps.shopify.com/pixel-proof): Grades your server-side tracking — coverage, event deduplication and match-key quality — with a fix list ordered by the money at stake. - **FAQ Genie** (https://apps.shopify.com/faq-genie): Turns your real store policies into grounded FAQs with structured data — one answer per real fact, never invented. - **Blog Smith** (https://apps.shopify.com/blog-smith): Turns a keyword into a competitive brief, then a draft grounded in your own catalogue, scrubbed of the usual AI tells, published to your blog. - **Flow Fill** (https://apps.shopify.com/flow-fill): Audits your Klaviyo lifecycle flows, builds the ones that are missing in your brand voice, and reports the revenue they actually earned. - **TermForge** (https://apps.shopify.com/termforge): Takes a deposit at checkout and captures the balance automatically on fulfilment, with retries — for custom and big-ticket orders. - **Rank Forge** (https://apps.shopify.com/rank-forge-1): On-page SEO audit with reversible fixes — titles, metadata and structure, applied to the pages that already get traffic. - **Access Proof** (https://apps.shopify.com/accessproof-3): Finds and fixes the accessibility problems on your storefront, so the shop works for everyone and stays on the right side of the law. - **MergeGuard** (https://apps.shopify.com/mergeguard-1): Combines a customer’s duplicate orders into a single parcel — one shipment, one label, and books that still balance. - **Omnibus** (https://apps.shopify.com/omnibus-1): Automatic EU 30-day lowest-price display on every discounted product, so promotions comply with the Omnibus directive without manual work. - **Pick Forge** (https://apps.shopify.com/pick-forge): Turns any phone or handheld scanner into a scan-and-pack station: scan the courier label to open the order, barcode-check every item with a mispick alarm, and fulfil with tracking. Staff sign in with a PIN. - **Safety Label** (https://apps.shopify.com/safety-label): Audits up to 500 products against the EU GPSR safety fields, ranks the gaps worst-first, fills them in a guided editor and shows them on the product page. It measures data completeness, not legal compliance. ### Google Workspace Marketplace — live - **Statwise** — Google Sheets(TM) (https://workspace.google.com/marketplace/app/statwise_%e2%80%94_statistics_regression_anova/930365558402): Sixteen Analysis ToolPak-style analyses — descriptive statistics, t-tests, ANOVA, F-test and full regression tables — inside Sheets, validated against certified reference datasets. - **Icons** — Slides(TM) & Docs(TM) (https://workspace.google.com/marketplace/app/icons_%e2%80%94_125000_icons_for_slides_docs/740142106624): A searchable library of 125,000 icons in 28 matched styles that you recolour and drop straight onto a slide. No account, no sign-in. - **Fontly** — Docs(TM), Sheets(TM), Slides(TM) (https://workspace.google.com/marketplace/app/fontly_%e2%80%94_font_browser_preview_apply/204654036017): Browse 1,800+ fonts, each drawn in its own typeface, filter by style or writing script, and apply one to your selection without leaving the document. - **Change Case** — Google Sheets(TM) (https://workspace.google.com/marketplace/app/change_case_%e2%80%94_uppercase_lowercase_title/227755678327): Converts the text in the cells you selected to UPPERCASE, lowercase, Title Case or Sentence case — thousands of cells in about a second, never touching numbers, dates or formulas. - **Alphabetize** — Google Docs(TM) (https://workspace.google.com/marketplace/app/alphabetize_%e2%80%94_sort_paragraphs_lists/1027946250236): Sorts the lines, list items or table rows you selected A→Z or Z→A, showing you the new order before it touches the document, with undo after. - **Outline** — Google Docs(TM) (https://workspace.google.com/marketplace/app/outline_%e2%80%94_toc_sidebar_heading_navigation/10181938639): A live side panel listing every heading in the document, indented by level, so you can see the whole structure and jump anywhere in one click. - **Highlighter** — Google Docs(TM) (https://workspace.google.com/marketplace/app/highlighter_%e2%80%94_colourcoded_notes_extracts/49023258864): Turns plain highlighting into usable qualitative coding — mark passages into named categories, count them, and extract the marked text into the document. - **Prism** — Google Docs(TM) (https://workspace.google.com/marketplace/app/prism_%e2%80%94_code_syntax_colour_blocks/1035461326058): Turns a block of pasted code into something readable inside Docs — monospaced, with keywords, strings, comments and numbers each in their own colour, for the common languages: JavaScript, Python, SQL, JSON and more. One click puts the block back. - **Pinpoint** — Google Sheets(TM) (https://workspace.google.com/marketplace/app/pinpoint_%e2%80%94_postcode_to_latitude_longitud/824476325418): Turns a column of addresses into latitude and longitude columns, with a dry run first and results written only into new columns so nothing of yours is overwritten. - **Recur** — Gmail(TM) (https://workspace.google.com/marketplace/app/recur_%e2%80%94_repeating_email_on_a_schedule/1063421331697): Write an email once in the Gmail side panel and have it sent on a repeating schedule — hourly, daily, weekly, on weekdays or monthly. - **Cards** — Gmail(TM) (https://workspace.google.com/marketplace/app/cards_%e2%80%94_email_to_trello_from_gmail/377247475166): Turns an e-mail into a Trello(TM) card without leaving Gmail(TM) — the subject becomes the title, the message becomes the description, and you pick the board and list from the side panel. - **Unbreak** — Google Docs(TM) (https://workspace.google.com/marketplace/app/unbreak_%e2%80%94_rejoin_paragraphs_join_lines/881117561424): Repairs text pasted from a PDF, an email or a transcript: rejoins the lines broken mid-sentence and leaves the real paragraphs alone. It never changes a word. Preview before, undo after. - **Cadence** — Google Sheets(TM) (https://workspace.google.com/marketplace/app/cadence/233863677748): E-mails a range from your sheet on a schedule — daily, weekdays, weekly, monthly or hourly — and records every run as sent, skipped or stopped, with the reason. One button stops everything. - **Goal Seek** — Google Sheets(TM) (https://workspace.google.com/marketplace/app/goal_seek_%e2%80%94_whatif_analysis/510725862904): Answers the backwards question: name the formula cell, the number it must reach and the one cell it may change, and it searches until it lands — with the cost shown and one-click undo. - **Rainbow** — Google Docs(TM) (https://workspace.google.com/marketplace/app/rainbow_%e2%80%94_fun_text_colours_effects/308042749484): Colours the text you select — gradients across the words, alternating letters, letters that grow, or the line turned upside down — from six readable palettes, with one press to restore the original. ### Atlassian Marketplace — live - **SprintEstimate** — Jira, project page (https://marketplace.atlassian.com/apps/4247878580/sprintestimate): Planning poker inside a Jira project: votes stay hidden until they are revealed, then the agreed number is written to Story Points — after checking the field is actually on the edit screen. - **Epic Roll-up** — Jira, project page (https://marketplace.atlassian.com/apps/2208190921/epic-roll-up): Lists every epic in a project and counts its children, showing issues done against the total, and story points done against the total, whichever story-point field the site uses. - **Recurring Issues** — Jira, project page · hourly trigger (https://marketplace.atlassian.com/apps/2741121130/recurring-issues): Per-project rules that create an issue daily, weekly or monthly at a wall-clock time in a chosen timezone — daylight saving included, and checked against the create screen first. - **Templatize** — Jira, project page (https://marketplace.atlassian.com/apps/2413127167/templatize): Reusable issue templates — type, summary, description, priority, labels, sub-tasks — that name any required field that would block creation, instead of failing halfway. ### Atlassian Forge — built, not publicly listed - **Dep Chains** — Jira, issue panel · project page: Walks the blocks/is-blocked-by link graph breadth-first to show what an issue waits on and what waits on it, plus a project list ranked by open blockers. - **Time in Status** — Jira, project page: Rebuilds each issue’s status changelog into contiguous segments, then reports average and total time per status and how long an issue has sat where it is now. - **Deadliner** — Jira, project page · daily trigger: Lists a project’s overdue and soon-due issues, and once a day comments on each one mentioning the assignee — deduplicated per issue and due date, so nobody is nagged twice. - **CloneCraft** — Jira, issue panel: Copies an issue up to twenty times, keeping only the fields the issue type’s create screen accepts, optionally with its sub-tasks and links, and telling you what it had to drop. - **FieldTrail** — Jira, issue panel: Reads an issue’s changelog and lists every field change as from → to, with author and timestamp, filterable by field and by person, exportable as CSV. - **FreshPage** — Confluence, page byline · space page: Gives a page a review date and interval, flags it when it has been edited since the last review, lists what is overdue across a space, and posts one reminder per cycle. - **PageStamp** — Confluence, page byline · space page: Approval requests and decisions on a page, pinned to the exact version approved — so a later edit shows the approval as stale instead of quietly carrying it forward. - **ReadMark** — Confluence, page byline · space page: Marks a page as required reading and records each confirmation against the page version — a new version asks the same readers again. - **AccessLens** — Confluence, page byline · space page: Shows who can actually see a page: its own view and edit restrictions, or — when it has none — the nearest ancestor page whose restriction actually applies. - **Mermaid Studio** — Confluence, editor macro: Renders Mermaid diagrams — flowchart, sequence, Gantt, class, ER, state, pie, mindmap — natively in a page, with a live editor, themes and export. - **Markdown** — Confluence, editor macro: Writes and renders Markdown inside a Confluence page, in the editor and in the published view, with dark mode. - **Callout Studio** — Confluence, editor macro: Info, note, tip, success, warning, error and quote panels with Markdown bodies, edited in place and collapsible. - **Steps Studio** — Confluence, editor macro: Numbered step-by-step procedures with Markdown detail under each step, edited live on the page. - **Decision Studio** — Confluence, editor macro: Architecture decision records as a structured block — status, context, decision, consequences — instead of four paragraphs nobody can diff. - **Tabs Studio** — Confluence, editor macro: Splits a section of a page into clickable tabs, each with its own Markdown body. - **Timeline Studio** — Confluence, editor macro: Milestones with dates, status and Markdown notes, drawn as a timeline you can read at a glance. - **Poll Studio** — Confluence, editor macro: Readers vote on the page itself; results show as live bars, one vote per person, stored with the page. - **FAQ Studio** — Confluence, editor macro: Collapsible question-and-answer pairs with Markdown answers, edited on the page. - **Cards Studio** — Confluence, editor macro: A responsive grid of titled cards with icons, Markdown and links — a landing block for a space home page. - **Glossary Studio** — Confluence, editor macro: A–Z terms with Markdown definitions, sorted automatically under letter headers. - **Comparison Studio** — Confluence, editor macro: Compares options across criteria in a table of tick, cross or text cells, edited live. ### monday.com — built, sent back at first review, not listed - **Recap** — monday.com, board view: Reads a monday board and writes a plain-language summary of what moved, what is blocked and what is due, exportable as a PDF. ## Numbers, with sources - **20** live on the Shopify App Store — source: apps.shopify.com/partners/devshophub lists 20 apps; every handle returns 200 with partner devshophub (measured 11 October 2026) - **15** live on the Google Workspace(TM) Marketplace — source: workspace.google.com/marketplace — every listing returns 200 and links back to goingstellar.ro (measured 11 October 2026) - **4** Atlassian Forge apps live on the Atlassian Marketplace — source: marketplace.atlassian.com/vendors/12665203 lists 4; each /apps/ redirects to its public page (measured 11 October 2026) - **21** Atlassian Forge apps built, not yet listed — source: manifest.yml on disk; marketplace.atlassian.com/apps/ returns the generic unlisted page, the same as a made-up id (measured 11 October 2026) - **1** monday.com(TM) board view built, submitted, sent back at first review, not listed — source: monday.com marketplace — not in the public catalogue (measured 11 October 2026) - **26** online stores in the fulfilment database — source: COUNT(DISTINCT store_uid) FROM orders (measured 31 August 2026) - **7** countries with delivered orders — RO, CZ, PL, BG, SK, HU, MD — source: GROUP BY shipping_address->>'country_code' (measured 31 August 2026) - **7** currencies — RON, CZK, PLN, BGN, EUR, HUF, MDL — source: COUNT(DISTINCT currency) FROM orders (measured 31 August 2026) - **754,934** orders passed through the fulfilment systems — source: COUNT(*) FROM orders (measured 31 August 2026) ### Orders by country - Romania: 643,277 - Czechia: 69,033 - Poland: 22,694 - Bulgaria: 19,274 - Slovakia: 239 - Hungary: 189 - Moldova: 114 ## Methods 52 playbooks: how we build apps for each marketplace, how we make games, and how we decide what to build. Each has a Markdown mirror and is licensed CC BY 4.0. - Index of all of them, in one request: https://goingstellar.ro/methods/index.md - Every playbook also exists in Romanian under /ro/metode/. - [The demand test: how we decide whether an app is worth building](https://goingstellar.ro/methods/demand-test-how-we-decide-whether-an-app-is-worth-building/): Before we write a line of code we ask one question of the marketplace: is anyone arriving in this niche now, and is anyone paying? We answer it from public data we scan ourselves, measure flow rather than lifetime totals, read complaints in full, count people rather than seats, and refuse to size a market until we can name the platform mechanism the product depends on. Markdown: https://goingstellar.ro/methods/demand-test-how-we-decide-whether-an-app-is-worth-building.md - [Triple deep research, fused into one roadmap](https://goingstellar.ro/methods/triple-deep-research-fused-into-one-roadmap/): At the start of every project, and again before every major version, we run the same research question through three AI deep-research engines and fuse the reports into one master roadmap. The value comes from the disagreements between engines and from verifying every load-bearing claim at source; the reports themselves are drafts, not findings. Markdown: https://goingstellar.ro/methods/triple-deep-research-fused-into-one-roadmap.md - [How we build a Shopify app](https://goingstellar.ro/methods/how-we-build-a-shopify-app/): We build every Shopify app on one shared chassis, keep the logic that makes it different in a pure module we can test for free, and treat the payment path as unproven until a real click reaches Shopify's approval page. Most of the method is a list of checks we added after something broke. Markdown: https://goingstellar.ro/methods/how-we-build-a-shopify-app.md - [How we build a Google Workspace add-on](https://goingstellar.ro/methods/how-we-build-a-google-workspace-add-on/): We build each add-on on Apps Script and CardService with the narrowest scope that does the job, then take it through consent, verification and review. Approval proves little: what kept our add-ons working was testing on a clean account, testing what they write, and counting people rather than seats. Markdown: https://goingstellar.ro/methods/how-we-build-a-google-workspace-add-on.md - [How we build and ship Atlassian Forge apps](https://goingstellar.ro/methods/how-we-build-and-ship-atlassian-forge-apps/): We built 25 Jira and Confluence apps on a few shared chassis and submitted them together. Four are public; the other 21 were rejected and are stuck behind a listing state only Atlassian can clear, because we skipped the step that would have shown us the wall: taking one app all the way through review first. Markdown: https://goingstellar.ro/methods/how-we-build-and-ship-atlassian-forge-apps.md - [How we build a monday.com app](https://goingstellar.ro/methods/how-we-build-a-monday-app/): We built one monday.com app, a board view that writes a short AI summary of a board, and submitted it in August 2026. It was rejected in September for overlapping apps that already exist. The build method held up; the step we skipped was asking monday, before writing code, whether the concept would clear its duplication policy. Markdown: https://goingstellar.ro/methods/how-we-build-a-monday-app.md - [How we build an iPhone app](https://goingstellar.ro/methods/how-we-build-an-iphone-app/): We test demand with our own App Store scanner before writing code, keep the engine in a package that can be verified without Xcode, and treat a clean install on the simulator as our first reviewer. Our worked example, Null Field, is built and ready to submit, but it has not been submitted. Markdown: https://goingstellar.ro/methods/how-we-build-an-iphone-app.md - [How we build an Android app for Google Play](https://goingstellar.ro/methods/how-we-build-an-android-app-for-google-play/): We read Google Play's gates and measure demand before writing code, build a native Kotlin and Compose app, then look at it running: our worst defects were visible only on screen. Our first app is built but not published, and our own research says organic Play distribution will not carry it. Markdown: https://goingstellar.ro/methods/how-we-build-an-android-app-for-google-play.md - [Recording demo screencasts that pass marketplace review](https://goingstellar.ro/methods/recording-demo-screencasts-that-pass-marketplace-review/): We record the page, not the screen, in a dedicated browser, with steps addressed by selector and captions burned in. Each beat of the film shows one thing the requested permission is used for, so the reviewer can see the app do what the listing claims. Most of our rejected takes came from the surface and from leftover state, not from the app. Markdown: https://goingstellar.ro/methods/recording-demo-screencasts-that-pass-marketplace-review.md - [How to answer privacy, GDPR and security questionnaires for marketplace apps](https://goingstellar.ro/methods/privacy-gdpr-security-questionnaires-marketplace-apps/): Every app marketplace asks what data the app touches, where it goes, who else processes it and what protects it. We answer every form from one data map per app, back each Yes with a public page or a contract, and treat a No that blocks approval as a reason to build, not to reword. Markdown: https://goingstellar.ro/methods/privacy-gdpr-security-questionnaires-marketplace-apps.md - [How we make a game in Godot](https://goingstellar.ro/methods/how-we-make-a-game-in-godot/): We are building SOLOMONAR, a first-person folk-horror game set in a Maramureș village over seven days, with agents doing most of the typing and one person deciding. The method rests on three decisions taken on day one, a world that can be checked without a screen, and one rule: no automated check is trusted until we have seen it fail on a world we broke on purpose. Markdown: https://goingstellar.ro/methods/how-we-make-a-game-in-godot.md - [Game assets on a small budget: CC0 characters, animation retargeting and local Romanian voices](https://goingstellar.ro/methods/game-assets-on-a-small-budget/): For SOLOMONAR we take people, animations, voices and music from free sources and run the voice generation on our own machines. The method is to read every licence inside the file, under the heading it sits under, and to check the whole chain an asset came from. That reading reversed one of our own decisions: the Romanian voice we had cleared turned out not to be cleared. Markdown: https://goingstellar.ro/methods/game-assets-on-a-small-budget.md - [Publishing without invented numbers: how we write these articles](https://goingstellar.ro/methods/publishing-without-invented-numbers/): The articles on this site are drafted by AI agents, checked by other agents and then by a script with no model in it. The method rests on one finding from the first round: language models invent quantities in words as readily as in digits, so a checker that only reads digits passes most of what it should stop. Markdown: https://goingstellar.ro/methods/publishing-without-invented-numbers.md - [Working with a fleet of AI coding agents without losing work](https://goingstellar.ro/methods/working-with-a-fleet-of-ai-coding-agents/): We run many AI coding agents at once, often on the same files and the same systems. Almost nothing we lost came from bad code. It came from coordination: agents sharing a scratch space, two runs writing one live file, a deploy that published someone else's half-finished work, and runs that died together and resumed blind. These are the rules we follow now. Markdown: https://goingstellar.ro/methods/working-with-a-fleet-of-ai-coding-agents.md - [The honesty gate: no feature claim without proof in the code](https://goingstellar.ro/methods/honesty-gate-no-claim-without-proof/): Before any submission we compare every claim on a plan page, a listing or a public page with the code that is supposed to back it. A claim stays only if a merchant can reach a working implementation; otherwise we build the feature or delete the claim. A script catches the mechanical cases, and the rest is read by hand. Markdown: https://goingstellar.ro/methods/honesty-gate-no-claim-without-proof.md - [How to make a website that AI assistants can read and cite](https://goingstellar.ro/methods/how-to-make-a-website-ai-assistants-can-read-and-cite/): Publish every page twice, as HTML for people and as Markdown for machines, and generate both from one source so they cannot disagree. Add an llms.txt index, an Atom feed, Article structured data that points to the Markdown, and a robots.txt that names the AI crawlers. Then check each file by what the server actually sends, not by whether it answers. Markdown: https://goingstellar.ro/methods/how-to-make-a-website-ai-assistants-can-read-and-cite.md - [How to make a Shopify store readable by AI assistants (GEO for e-commerce)](https://goingstellar.ro/methods/how-to-make-a-shopify-store-readable-by-ai-assistants/): Generative engine optimisation (GEO) for a Shopify store is ordinary SEO aimed at AI answers: let the AI crawlers in, write pages that answer questions in quotable passages, add structured data that is true, give Shopify's own assistant real FAQs, and measure visits that arrive from AI referrers. This is the method we built into our Shopify app Cite Me, with the mistakes that shaped it. Markdown: https://goingstellar.ro/methods/how-to-make-a-shopify-store-readable-by-ai-assistants.md - [How we turn repeated work into AI agent skills](https://goingstellar.ro/methods/turn-repeated-work-into-ai-agent-skills/): An agent skill is a folder with a SKILL.md file that tells an AI coding agent when and how to do one recurring job, plus any script the job needs. We extend an existing skill before creating a new one, make every write a dry run by default, and keep the rules that must hold outside the prompt: in hooks, read-only database roles and a shared knowledge base. Markdown: https://goingstellar.ro/methods/turn-repeated-work-into-ai-agent-skills.md - [How to calculate breakeven CPA for a cash-on-delivery store](https://goingstellar.ro/methods/breakeven-cpa-cash-on-delivery/): Breakeven CPA is the most a store can pay in advertising for one placed order before that order loses money. For a cash-on-delivery store it is revenue without VAT, minus goods and shipping, counted only on delivered orders, minus the shipping already paid on every refused parcel. We scale only below 70% of that number. Markdown: https://goingstellar.ro/methods/breakeven-cpa-cash-on-delivery.md - [How to judge ad campaigns on profit, not on target CPA](https://goingstellar.ro/methods/judge-ad-campaigns-on-profit/): To decide whether to scale, hold or cut an ad campaign, compare its cost per order with the store's breakeven CPA, not with a target CPA someone chose. Platform-reported ROAS is inflated, so CPA is the safer gate; brand search and ads on sold-out products need their own tests, because both look different in the report than in the bank account. Markdown: https://goingstellar.ro/methods/judge-ad-campaigns-on-profit.md - [How to catch silent data failures: freshness watchdogs, heartbeats and reconciliation](https://goingstellar.ro/methods/catch-silent-data-failures/): A silent data failure is a pipeline that reports success while its data is stale or wrong. We catch them with three scheduled checks: a watchdog that reads the newest row instead of the job status, heartbeats that fire only on success, and a daily reconciliation of the same number from two independent sources. The checks themselves were wrong several times, and those mistakes are part of the method. Markdown: https://goingstellar.ro/methods/catch-silent-data-failures.md - [Safe deploys: dry-run by default, parity checks and verifying what actually shipped](https://goingstellar.ro/methods/safe-deploys-dry-run-parity-checks/): A successful deploy command proves that a transfer happened, not what was transferred. We make deploys safe with three habits: every deploy script is a dry run unless given --apply, a daily parity check compares server files with git, and after publishing we compare the served source and response bodies with what we meant to ship. Markdown: https://goingstellar.ro/methods/safe-deploys-dry-run-parity-checks.md - [Testing a website at phone width: why headless screenshots lie](https://goingstellar.ro/methods/test-website-phone-width-headless-screenshots/): Headless Chrome will not lay a page out narrower than about 500 CSS pixels, so a 390-pixel screenshot is a crop of a wider page, not a phone. We test phone widths with device emulation over the Chrome DevTools Protocol and then measure the page instead of looking at it. Six traps on our own site were caught only that way. Markdown: https://goingstellar.ro/methods/test-website-phone-width-headless-screenshots.md - [How Shopify Admin API tokens expire and refresh: permanent tokens, expiring tokens and the refresh traps](https://goingstellar.ro/methods/shopify-admin-api-tokens-expiring-refresh/): Shopify Admin API tokens come in kinds that must be handled differently: a permanent custom-app token must never go through refresh logic, while an expiring token must be refreshed with a single-use refresh token that you persist every time. Most of our token outages were not expired tokens but refresh logic applied to the wrong token, or two refreshes racing each other. Markdown: https://goingstellar.ro/methods/shopify-admin-api-tokens-expiring-refresh.md - [How to deploy Google Apps Script code through the API instead of copy-paste](https://goingstellar.ro/methods/deploy-google-apps-script-via-api/): Google Apps Script code can be read and written through the Apps Script API, so a script no longer has to be pasted into the editor by hand. Our deploy tool runs as a dry run by default, backs up the current project before every write, re-sends every file because the API replaces the whole project, and verifies the result twice: by reading the code back and by checking what the script wrote. Markdown: https://goingstellar.ro/methods/deploy-google-apps-script-via-api.md - [Fail-safe caches and backfills: never delete history before the new data arrives](https://goingstellar.ro/methods/fail-safe-caches-and-backfills/): A fail-safe cache is one where a failed refresh leaves the previous data untouched: write with a pure upsert, never delete-then-reinsert, and retry the timeout exception your client library really raises. Backfills fill gaps with IS NULL instead of rewriting decisions, snapshots check themselves on read, and a value from today never answers a question about the past. Markdown: https://goingstellar.ro/methods/fail-safe-caches-and-backfills.md - [SQLite and database traps in small production apps](https://goingstellar.ro/methods/sqlite-and-database-traps-small-production-apps/): The database traps that hurt small production apps are not about scale: a long read on SQLite blocks every writer, a filter on an encrypted column silently matches nothing, DISTINCT fails on a table with a json column, commit() and rollback() expire ORM objects even inside the error handler, and a fixed epsilon is too small for a float32 column. Each one passes a quick test and fails in production, usually without a clear error. Markdown: https://goingstellar.ro/methods/sqlite-and-database-traps-small-production-apps.md - [How to check product images before they go live: the traps in automated packshots and infographics](https://goingstellar.ro/methods/product-images-packshots-infographics-traps/): Every serious product-image fault we found passed our automated checks: a clean packshot missing half the product, an infographic with correct dimensions drawn over another product, a sharp file that turned blurry once cropped. The method is to open every image and compare it with an independent reference for the physical product, not with other pictures from the same supplier pack. Markdown: https://goingstellar.ro/methods/product-images-packshots-infographics-traps.md - [How to mine Google Ads search terms and negative keywords without cutting conversions](https://goingstellar.ro/methods/google-ads-search-terms-negative-keywords/): Mine Google Ads search terms by grouping the real queries into words and short phrases (n-grams), add negative keywords only for patterns that are clearly outside what the store sells, and audit existing negatives for ones that block buyers. Zero conversions on a single search term is not proof of waste: on Shopping and Performance Max most sales come from a long tail of queries that each convert rarely. Markdown: https://goingstellar.ro/methods/google-ads-search-terms-negative-keywords.md - [How to avoid a Google Merchant Center misrepresentation suspension, and keep the feed healthy](https://goingstellar.ro/methods/merchant-center-misrepresentation-suspension/): A Merchant Center misrepresentation suspension usually comes from trust signals, not from checkout: a hidden or contradictory company identity, missing policy pages, empty business information, and on-site claims that are not true, such as countdowns that restart for every visitor or invented sales counts. Fix every signal before you request a review, because review requests are limited, and run a weekly feed-health check so product-level problems never pile up. Markdown: https://goingstellar.ro/methods/merchant-center-misrepresentation-suspension.md - [How to detect creative fatigue on Meta and TikTok ads](https://goingstellar.ro/methods/detect-creative-fatigue-meta-tiktok/): Creative fatigue is visible as a pattern, not as one bad metric: frequency climbing while click-through rate falls or cost per purchase rises. We compare the last 7 days of each ad account with the 21 days before, flag an account only when frequency is up more than 15% and CTR is down more than 10% or CPA is up more than 20%, and only then drill down to individual ads. Most of our false alarms came from the data, not from the audience. Markdown: https://goingstellar.ro/methods/detect-creative-fatigue-meta-tiktok.md - [How to audit email and SMS lifecycle flows before writing new campaigns](https://goingstellar.ro/methods/audit-email-sms-lifecycle-flows/): Before writing another email campaign, check which automated lifecycle flows the store already runs against a standard list of 10, whether each one is live or only a draft, and how much the email platform over-credits itself compared with your analytics. Then check the two things no flow audit shows: that consent withdrawals reach the sending platform at once, and that segments are built on customers who actually received their orders. Markdown: https://goingstellar.ro/methods/audit-email-sms-lifecycle-flows.md - [How to catch duplicate orders before the shipping label is printed](https://goingstellar.ro/methods/catch-duplicate-orders-before-the-label/): A duplicate order is the same customer placing the same order twice, usually minutes apart. We catch duplicate orders by running the duplicate decision inside the step that creates the shipping label, matching on the normalised phone number, and cancelling automatically only pairs that are strictly identical; everything else goes to a person. Our first detector decided correctly but too late, and most of what we know came from the cases it got wrong. Markdown: https://goingstellar.ro/methods/catch-duplicate-orders-before-the-label.md - [Ghost shipments: labels created but never scanned by the courier, and how to find them](https://goingstellar.ro/methods/ghost-shipments-labels-never-scanned/): A ghost shipment is a parcel whose shipping label exists but which the courier never scanned at pickup, while the customer has already been told the order shipped. We find ghost shipments by asking the courier, not our own records, for the last event of every label older than a few days, treating a missing answer as a failed read rather than a status, and sending every case to a person instead of remaking labels automatically. Markdown: https://goingstellar.ro/methods/ghost-shipments-labels-never-scanned.md - [How to use AI to draft customer service replies without letting it send them](https://goingstellar.ro/methods/ai-drafted-customer-service-replies/): We let a language model draft customer service replies from the team's approved macros and from procedures learned from real agent replies, grounded in the customer's actual order data. A human reviews and sends every reply; the model never sends. The guards around the draft matter more than the prompt, and most of what we learned came from guards that were silently not working. Markdown: https://goingstellar.ro/methods/ai-drafted-customer-service-replies.md - [How to catch refunds that were promised but never paid: a refund watchdog for customer service](https://goingstellar.ro/methods/refund-watchdog-promised-refunds-never-paid/): A refund watchdog is a read-only report that finds customers who are owed money the store never returned: card orders cancelled without a refund, and accepted returns that were sent to payment but never paid. It runs two plain rules against the order and return records and lists the oldest cases first. Our worst refund failure was a refund button that had never worked while every ticket showed as refunded. Markdown: https://goingstellar.ro/methods/refund-watchdog-promised-refunds-never-paid.md - [How to calculate reorder quantity and stock-out date per SKU from daily inventory snapshots](https://goingstellar.ro/methods/reorder-planning-daily-inventory-snapshots/): Reorder quantity per SKU is daily sales velocity times the days until the next delivery plus a safety buffer, minus what is on hand and what is already on the way. Velocity can be read from daily inventory snapshots, but only after correcting for restocks, stock-outs and duplicated stock rows, and the incoming figure must come from what was actually loaded, not from what was ordered. Markdown: https://goingstellar.ro/methods/reorder-planning-daily-inventory-snapshots.md - [How to build a warehouse barcode scanning app for Android rugged scanners](https://goingstellar.ro/methods/warehouse-scanning-app-android-rugged-scanners/): A warehouse scanning app turns a barcode read on a rugged Android terminal into an answer the picker can act on in a glance. We take the scan from the hardware scanner as a broadcast, route every screen through the same scan handler, write status as short field labels with colour for severity, and test every text colour against every background in both the light and the dark theme. Most of our bugs were a fix that existed on one screen and not on the others. Markdown: https://goingstellar.ro/methods/warehouse-scanning-app-android-rugged-scanners.md - [How to print shipping labels from several warehouse stations without losing or double-printing labels](https://goingstellar.ro/methods/label-printing-several-warehouse-stations/): When several warehouse stations print shipping labels from the same orders, the safe design is one print queue in which downloading a label is the act that removes it from every station's queue. We route each label to one station with a single shared rule, filter before we download, mark as printed everything we export by any other path, and send a batch as one complete document. Every lost or double-printed label we had came from a download that happened in the wrong place or at the wrong time. Markdown: https://goingstellar.ro/methods/label-printing-several-warehouse-stations.md - [Normalising delivery addresses across countries: postcodes, Cyrillic, street-length limits](https://goingstellar.ro/methods/normalising-international-delivery-addresses/): Address normalisation turns what a customer typed into the form a courier accepts and a national register confirms. We do it per country: first we find out whether that country's postcode describes a street or a whole locality, then we validate against a register whose flaws we know, normalise the register and the search with the same function, and send the courier its own spelling and identifiers. Most of the failures we hit were in the plumbing around the address, not in the address. Markdown: https://goingstellar.ro/methods/normalising-international-delivery-addresses.md - [Handling GDPR erasure requests across the store, helpdesk and email tools](https://goingstellar.ro/methods/gdpr-erasure-requests-store-helpdesk-email/): A GDPR erasure request is a customer asking an online store to delete their personal data. We stop marketing to that person within the hour, map every system that holds a copy, delete only by exact keys, and install a deletion ledger before deleting anything, because nightly syncs rebuild what you erase. Some records, such as invoices, must be kept by law, so the reply never says "we deleted everything". This is our working method, not legal advice. Markdown: https://goingstellar.ro/methods/gdpr-erasure-requests-store-helpdesk-email.md - [How to write SEO blog articles for an online store without invented facts](https://goingstellar.ro/methods/seo-articles-online-store-no-invented-facts/): An SEO article on a store blog may only mention products that exist and are in stock, and only claims the store can prove. We ground the writer in a catalogue exported from the store, have a second agent try to refute each draft, and let a script with no model in it block publication on fake product links, banned words and invisible AI characters. Markdown: https://goingstellar.ro/methods/seo-articles-online-store-no-invented-facts.md - [How to calculate profit per product for a cash-on-delivery store](https://goingstellar.ro/methods/profit-per-product-cash-on-delivery/): Profit per product is the contribution each SKU leaves after its own costs: revenue without VAT, minus cost of goods, transport and marketing, counted on delivered orders only. Free gift units carry their real cost, transport comes from the real cost of each order with a fallback cascade, and marketing is allocated per order. The hard part is not the formula but taking each input from the right source. Markdown: https://goingstellar.ro/methods/profit-per-product-cash-on-delivery.md - [How to allocate ad spend to products when ads don't map one-to-one](https://goingstellar.ro/methods/allocate-ad-spend-to-products/): To allocate advertising cost to products, read cost at the level of the individual ad, match each ad to a product through a fixed ladder of rules (exact SKU code, then keyword, then account), split shared ad accounts by a token in the campaign name, and divide what remains across products by number of orders. Every rule fails silently in a specific way, so the method is mostly about catching those failures. Markdown: https://goingstellar.ro/methods/allocate-ad-spend-to-products.md - [How to compare store profitability across EU countries: VAT rates, currencies and exchange rates](https://goingstellar.ro/methods/compare-store-profitability-eu-countries-vat-currency/): To compare stores that sell in different EU countries, remove each destination country's VAT from revenue, convert every order from its own currency at a dated exchange rate, take costs without the VAT on their own invoices, and only then compare contribution per delivered order. Most of our cross-country errors came from a wrong VAT divisor, a stale exchange rate or a cost read in the wrong currency. Markdown: https://goingstellar.ro/methods/compare-store-profitability-eu-countries-vat-currency.md - [Does a buy-two-get-one-free promotion make money? A per-order method](https://goingstellar.ro/methods/buy-two-get-one-free-promotion-profitability/): A buy-two-get-one-free (2+1) promotion makes money when a three-unit order, after the cost of all three units, one parcel and the advertising cost of one order, still leaves positive contribution. Measure it per order, split by units per order, on delivered orders only, with the free unit charged at full cost. Markdown: https://goingstellar.ro/methods/buy-two-get-one-free-promotion-profitability.md - [Customer segments, RFM and lifetime value for cash-on-delivery stores: why you must use delivered orders](https://goingstellar.ro/methods/customer-segments-rfm-ltv-cash-on-delivery/): RFM segmentation, cohort retention and customer lifetime value only mean something on a cash-on-delivery store if they are computed on delivered orders, because a parcel refused at the door is not revenue. We score every customer by recency, frequency and monetary value from delivered orders only, per store and per currency, and we find a single customer across systems by the last nine digits of the phone number. The hard part was not the formulas but making sure the search and the data use the same normalisation. Markdown: https://goingstellar.ro/methods/customer-segments-rfm-ltv-cash-on-delivery.md - [How to build "frequently bought together" from your own order data](https://goingstellar.ro/methods/frequently-bought-together-from-order-data/): A "frequently bought together" block can be computed from your own orders with market-basket analysis: count how often two products appear in the same order and rank the pairs by lift, which says how much more often they sell together than chance would predict. We compute pairs per store over the last 180 days, from a source that holds almost every order, with minimum counts that keep rare products from producing absurd lifts. The method finds real pairs; turning them into bundles was harder than the maths. Markdown: https://goingstellar.ro/methods/frequently-bought-together-from-order-data.md - [How to add product and UGC videos to Performance Max: from clip to asset group](https://goingstellar.ro/methods/pmax-video-assets-from-product-and-ugc-clips/): Performance Max does not take video files: every video asset is a YouTube video referenced by its ID, so the path is clip, checked MP4, upload to the brand's own YouTube channel, then a link to the asset group. We keep clips at 11 seconds or longer, deliver 16:9, 1:1 and 9:16 versions, attach a handful rather than dozens, and verify each step by reading the result back. Most of what went wrong for us came from steps that reported success without doing the work. Markdown: https://goingstellar.ro/methods/pmax-video-assets-from-product-and-ugc-clips.md - [Scheduling social media posts through an API: why "scheduled" does not mean "published"](https://goingstellar.ro/methods/scheduled-is-not-published-social-posting-api/): A scheduling API that answers HTTP 200 has accepted the post, not published it; publishing happens later and can fail separately on each network. In the period we measured, our poster logged success on every run while all 17 Facebook posts and 12 Instagram posts failed. We now read the final status per network after the publish time, expire every cached list of connected accounts, and check generated captions in code, on the text that actually leaves. Markdown: https://goingstellar.ro/methods/scheduled-is-not-published-social-posting-api.md - [How to audit a product landing page for conversion, step by step](https://goingstellar.ro/methods/landing-page-conversion-audit/): A conversion audit of a product landing page checks, in a fixed order, whether a visitor on a phone can see the offer, the price, the buy button and the reasons to trust the shop, and whether the page loads fast enough to be seen at all. The output is a list of hypotheses ranked by likely impact, not a proven lift: only an A/B test can say whether a fix raised conversion. Markdown: https://goingstellar.ro/methods/landing-page-conversion-audit.md - [How to monitor competitor prices for commodity products](https://goingstellar.ro/methods/monitor-competitor-prices/): To monitor competitor prices, keep a list that maps each of your products to the same product on competitor sites, read the price from the page's structured data every night, store every reading in an append-only history, and compare only fresh, plausible prices against yours. Matching the right product and keeping the feed alive are harder than reading the price. A price change based on the comparison is a decision to test, not a proven gain. Markdown: https://goingstellar.ro/methods/monitor-competitor-prices.md ### The demand test: how we decide whether an app is worth building Before we write a line of code we ask one question of the marketplace: is anyone arriving in this niche now, and is anyone paying? We answer it from public data we scan ourselves, measure flow rather than lifetime totals, read complaints in full, count people rather than seats, and refuse to size a market until we can name the platform mechanism the product depends on. **What this is for** Every idea we have looks good on the day we have it. The demand test is the step that sits between an idea and a repository. It is cheap, it runs from the terminal, and its job is to kill ideas before they cost us a month. The rule underneath it fits in one line: **in a category with real paying customers, somebody leaves a trace.** On most marketplaces the trace is reviews or installs. If apps exist in a niche and nobody reviews or installs them, somebody already built the thing and nobody bought it. *No incumbent is not a green light; it usually means nobody is paying.* That sentence killed our own first-choice idea in an hour: about 39 apps in the niche, zero reviews between them. This article describes the method. A companion research piece, *Four corrections before our market scanner meant anything*, documents the specific errors we had to fix in the scanner itself; we do not repeat them here. **One question, six marketplaces** We scan six marketplaces with the same method and a different demand signal on each, chosen because it is the most honest public number that marketplace gives away. **Shopify App Store:** reviews, because install counts are not public. **WordPress.org plugins:** active installs, a harder signal than reviews. **Atlassian Marketplace:** total installs, from a clean public API. **Google Workspace Marketplace:** the user count on each listing, plus relative search volume from Google Trends. **Google Play:** installs, an exact download count, the rating histogram and whether the app carries ads or in-app purchases. **Apple App Store:** no install figure exists at all, so we work from dated reviews and the in-app purchase prices on the product page, and we state conclusions as bands rather than numbers. The Chrome Web Store is not on the list: it has no usable API and blocks automated reading. Whatever the marketplace, the scan starts the same way: we define the niche with a relevance rule we control, never with the platform's own search ranking, and we check whether the platform ships its own free app for the job. On the Shopify App Store, 16 of 130 categories were led by the platform's own app when we measured. A free, preinstalled default ends the analysis on its own. **Flow, not stock** A lifetime review or install count only goes up. It records everyone who ever arrived, including people who left years ago. We call it stock. What matters for a new entrant is flow: how many people are arriving now, and to whom. So the scanner reads review dates. The sharpest single field is the number of days since an app's newest review. An app whose newest review is four months old is dying, whatever its total says. One app we found had 981 reviews and had been silent for 333 days. Another held 1,459 lifetime reviews, enough to look like a healthy player in its niche, while producing about 2 reviews in 90 days after 63 days of silence. A stock test counts it as a competitor. A flow test shows a vacancy. **Read the complaints, and read all of them** A leader's 1-2★ reviews are where we look for the product to build. A complaint about a missing feature is a specification written by someone who already pays. That part of the method survived. Two things changed in how we read them. First, the dominant complaint in every niche we profiled was price, so complaint *volume* shows who did not want to pay. Only feature complaints go into the case for building. Second, filtering reviews by rating produces a false timeline. In one hunt an agent read only the 1-2★ reviews of a document add-on and concluded it was functionally dead. Read without the filter, its newest review was 4★ and positive, and three of its four reviews from the previous year were positive too. We now read the full review list first and filter afterwards. We also check the neighbours, not just the leader. In one category all three solver add-ons sat between 2.49★ and 2.67★. When independent teams converge on the same low rating over a decade, the cause is the platform, not the craft. A wounded leader surrounded by equally wounded neighbours is not an opening. **Count people, not seats; count installs, not votes** On the Google Workspace Marketplace the public user count is roughly the number of seats. When an administrator installs an add-on for a whole domain, every employee is counted. We proved it on our own apps: the developer console showed that one of our most visible listings, **Change Case**, owed almost all of its public number to two organisations, while the people who had installed it themselves were a few dozen. **Alphabetize**, which looked like a minor app, had several times more of those people. We never judge a Workspace app, ours or a competitor's, by the public number again. Votes on a public feature-request tracker have the same problem in a different form. Two AI research reports recommended Atlassian's public backlog as a better hunting ground than the marketplace. We checked tickets against the installs the market actually produced. A ticket with 617 votes had led to an app with 9 installs; one with 102 votes, to an app with 14. Across the ten ideas worth checking, 4,339 votes had turned into about 440 installs spread over roughly 40 competing apps: about one install per hundred votes. A vote measures annoyance, not budget. The tracker is now a source of hypotheses; installs are the veto. **Name the module before the market** On developer platforms an idea can fail before demand even matters: the extension point it depends on may not exist. Of the ten Atlassian app ideas proposed by AI research, four rested on a mechanism the platform does not have: a module that intercepts publishing, a schema field holding a comment's previous text, an event for comment edits, and an endpoint that the documentation bars apps from calling. All four fail silently. The manifest deploys, the handler never fires, and you lose a week thinking it is a deployment problem. So the first gate, before any market analysis, is a sentence pasted from the platform's own developer documentation saying that the module performs the exact action the product depends on: blocks, reads, intercepts. If we cannot paste the sentence, the idea is dead and we do not size its market. **Where we were wrong** For weeks we ranked our own Workspace apps by the public user count. The ranking was inverted: the app we called the winner had only a few dozen people behind it, and a whole round of hypotheses written to explain its success was explaining a number that did not describe people. We also capped our own pipeline without saying so. In one round, 15 candidates went into a workflow that only passed the first ten to the gate. Five never reached it, including the two cheapest options we had. The gate is the filter; a silent cap is not. We now log anything a workflow drops. And we treated AI research as a finder of facts. When we checked the Atlassian proposals, the vote counts were exact in 13 of 13 cases, while status, date or the platform's position was wrong in 4 of 14, and every error ran in the direction that flattered the proposal. Precise where precision is cheap, wrong where being wrong helps. **Limits** Reviews and installs are proxies for paying customers, with an unknown and category-dependent conversion between them. We compare niches against each other; we do not read the numbers as absolute demand. Our flow estimates on the Shopify App Store come from a page that shows only about ten recent reviews, so for busy apps the rate saturates and two apps that read the same are not necessarily equal. On the Apple App Store there are no install figures at all. And the test says whether a niche is alive, not whether we can win it: that depends on the product, the wedge and the listing, which the scan cannot see. **Checklist** **1. Mechanism.** Paste the documentation sentence for the module the product needs. No sentence, no idea. **2. Free default.** Does the platform already ship this for free? If yes, stop. **3. Membership.** Define the niche with your own relevance rule and write it down. **4. Flow.** Measure arrivals from dated reviews and days of silence; treat lifetime totals as history. **5. Complaints.** Read every review first, then separate price complaints from feature complaints. Check the neighbours. **6. Units.** People, not seats. Installs, not votes. Write the unit next to every number. **7. Reports.** Verify every name and claim a research report gives you against the marketplace before it influences a decision. ### Triple deep research, fused into one roadmap At the start of every project, and again before every major version, we run the same research question through three AI deep-research engines and fuse the reports into one master roadmap. The value comes from the disagreements between engines and from verifying every load-bearing claim at source; the reports themselves are drafts, not findings. **What this is for** A first specification describes the obvious version of a product. The ritual exists to map the full feature space before we commit to an architecture: what the best competitors ship, what neighbouring industries do that nobody on our platform has copied, what a much better version would contain, and which of those ideas merchants actually keep using. We run it at kickoff, after the first version is live, and before each large version, because a live app changes what is worth building next. On one of our Shopify apps the fused research produced the rich-text editor and the whole third phase of the roadmap, none of which was in the original spec. We also used the same ritual on our own build-and-ship process, not just on products. **Step 1: write the prompt as a case file** The same prompt goes to all three engines. It asks for five things: everything best-in-class competitors ship, with citations to their listings; what adjacent tools do that no app on the platform has copied; what a far better version would include; every idea ranked by value against build effort; and what is overhyped compared with what actually retains users. When the question is strategic rather than a feature map, the prompt carries our own data, and that is where it goes wrong first. One draft of a strategy prompt contained five wrong figures, four of them material. One was a count quoted per week that had actually been measured over 28 days. Another was presented as retention and was in fact the install completion rate. So every load-bearing number is re-read from its primary source on the day the prompt is sent, with its time window and unit written next to it. We then attack the draft with six independent critics: a fact-checker with access to sources, a reader looking for leading questions, a consultant who lists what is missing, a reader with no context, a devil's advocate on the narrative, and one that answers the prompt and then judges its own answer. We keep what at least two of them found, plus anything proved wrong by one, and rewrite. The last critic is consistently the most useful: it is how we learned that six open questions produce six essays at a depth where nothing can be wrong. **Step 2: three engines, run in parallel** ChatGPT deep research and Gemini deep research are run by a person and the reports brought back. Claude runs its own multi-source pass at the same time, reading competitor listings, documentation and reviews directly. Each engine reliably finds things the others miss. In our runs ChatGPT gave the most breadth on competitor features, Gemini the strongest angles on the Google ecosystem and search, and Claude the implementation feasibility and the honest positioning. On our build-process research, three gaps were missed by both external engines and came only from the third leg. The point of three engines is not three times the content. It is that disagreement between them surfaces inventions that any single report would present with full confidence. **Step 3: fuse, don't stack** Stacking three reports gives you a long document with three versions of every idea. Fusing means: remove duplicates, cluster what is left into themes, then run an adversarial critique over the whole set. What did all three miss? What is hype? What would break our platform permissions or data-protection level? The output is one master roadmap in the app's repository, split into phases that can each ship on their own. We tag each item by how many sources converged on it. On one fusion we combined five passes and marked items by agreement across five, four or three of them. Convergence is a signal about priority, not proof of truth: five reports can repeat the same mistake if they read the same weak source. **Step 4: verify at source before anything depends on it** Deep-research reports are good at structure and weak at specifics. We measured this. Across six Gemini reports on one market, three delivered no app identifiers at all even though the prompt required them, which made half the deliverable impossible to check; and 2 of 37 identifiers were fabricated. In a larger round, 171 claims from nine reports were taken to primary sources and 46% did not hold: 40% refuted, 6% unverifiable. Raw figures copied from official records were mostly right. The interpretation layer built on top of them was where most errors lived. The errors had a direction. Numbers were inflated consistently upwards, a currency mix-up turned an ordinary margin into an extraordinary one, and every claim of white space, meaning that nobody offers something, failed whenever we tested it. So every name, number and mechanism a roadmap item depends on is checked against the marketplace, the official documentation or the public record before the item is scheduled. **The traps we hit, and how we catch them now** **The fact list propagates errors.** To stop agents inventing things we give them a fence of confirmed facts. The fence also carries our mistakes, with the same authority. Building a question bank, this happened four times; one inverted fact produced a question with no correct answer, and the explanation repeated the inversion, so the generator's own checker could not catch it. Three independent verifiers found it only because they read the official text in full rather than our list. Facts now live in a versioned file, each with its source and the date it was read, and verifiers are told to challenge the list, not just the output. **A shared verifier cancels a different angle.** In one sweep we ran five independent angles, each followed by an adversarial verifier. The verifier was a single prompt written for the main question. The fifth angle asked something different, so its findings had a different shape, and the verifier rejected all of them: 3 raw findings, 0 confirmed. One of the rejected findings was real and more urgent than the original question. Now each angle gets its own refusal criterion, and we report raw findings next to confirmed ones, because a zero made by the harness looks exactly like a zero made by reality. **Proposals name mechanisms that do not exist.** Of ten app ideas proposed by AI research for a developer platform, four depended on a module, a schema field, an event or an endpoint that the platform does not have or does not allow apps to use. Each of those fails silently after deployment. No roadmap item that depends on a platform mechanism is accepted until we can paste the documentation sentence saying the mechanism performs the action. **Circular validation.** One report returned a figure from our own prompt as if it were an independent finding and built four sections on it. We no longer put numbers into a prompt that we want the research to verify. We give the question, not the answer. **Where we were wrong** We have not always run all three legs. Once the Claude leg was throttled by the search service and came back with a false empty result, so its sections were written from our own knowledge of the codebase instead. Another time the ChatGPT quota ran out and all six reports came from one engine. Verification at source was the only defence left, and it was enough only because that marketplace could be scanned. On a channel we cannot scan, a single-engine set should not drive a decision. We also over-spent on process. A generic research workflow with adversarial voting consumed about a million tokens once and returned zero sources, because every fetch had been rate-limited. We now probe the rate limit with one search and one fetch before launching anything expensive, and for narrow technical questions a focused pass of parallel searches is cheaper and better. **Limits** The ritual widens the option space and orders it. It does not establish demand; that is a separate test against marketplace data. It does not prove that a feature will retain anyone, and convergence between engines can be shared error. A fused roadmap is a plan to be re-run when a phase lands, not a forecast. **Checklist** **1.** Gather facts first, from primary sources, with dates, windows and units. Then write the prompt. **2.** Run six critics over the draft; keep what two found and every proven error. **3.** Send the same prompt to all three engines. Do not skip a leg because one engine can search. **4.** Dedupe, cluster, critique, and write one phased master roadmap. **5.** Verify every name, number and mechanism an item depends on before it is scheduled. **6.** Give each verification angle its own criterion; report raw and confirmed counts together. **7.** Re-run the ritual when a phase ships. ### How we build a Shopify app We build every Shopify app on one shared chassis, keep the logic that makes it different in a pure module we can test for free, and treat the payment path as unproven until a real click reaches Shopify's approval page. Most of the method is a list of checks we added after something broke. **What this is for** This is the procedure we follow to take a Shopify app from an idea to an App Store listing. It is written from the apps we actually built and submitted, including the ones that were rejected, suspended or never found a buyer. Most of it records checks we did not have the first time. **Before any code: a demand veto, then three research passes** **1. Check that anyone is paying.** Before a spec, we run our marketplace scanner on the niche: how many apps, how many reviews, how fast the leader is gaining them. A dead or empty niche ends the idea there. When we measured it, 52% of apps in the store had zero reviews and 81% had fewer than ten. No customers is the default outcome. **2. Map what already ships.** We profile the leading competitors from their real listings and demo videos and sort their features into table stakes, differentiators and our gaps. We draw no architecture until we can say in one paragraph where we win. An honest version with a free tier that genuinely helps is a wedge we can hold. **3. Run three deep-research passes and fuse them.** The same prompt goes to three engines: what the best competitors ship, what adjacent tools do that no Shopify app has copied, what a 10× version would include, and how each idea ranks by merchant value against build effort. Each engine finds things the others miss. We deduplicate, critique the result adversarially (what is hype, what would break our data-protection level) and write one roadmap in phases that can each ship alone. We repeat this after v1 and before each major version. **The chassis and the pure engine** Every app starts as a copy of one reference app, the chassis: React Router 7, Polaris, App Bridge, Prisma on Postgres, BullMQ workers on Redis, GraphQL only, and Anthropic models behind a cost guard where an app generates text. The chassis already carries sessions, webhook verification, the mandatory privacy webhooks and billing. A new app changes only its delta: name, scopes, data models, services. The logic that makes an app different lives in a module with no database, queue or SDK imports. That module gets plain unit tests that cost nothing to run; this is how all 15 engines in the first wave were verified. The price of a chassis is fork drift. A fix that lands in the reference app after a fork never reaches the fork. When a chassis-level bug turns up in one app, we assume it is in every app until measured, and we measure production rather than reading code. In one sweep, four such bugs were present on 19 of 20 forks. **The debug method, and the bug classes we prime it with** Before a submission, or when a merchant reports many bugs, we run a multi-agent adversarial audit. Several finder agents each take one subsystem, primed with our known bug classes and told to report only user-reachable bugs with a concrete reproduction. Every finding then goes to a verifier whose default answer is that the bug is not real. On the first app we audited this way, 50 findings went in, 8 were refuted and 42 were confirmed. For a mid-size app we split by concern instead: admin screens; the buyer path; billing, auth, webhooks and privacy; and the honesty of core services. That last lane catches a function that is reachable and returns success for work it never did. The classes we prime with all came from our own code: a credit charged before the model output was validated; a form result read from the wrong hook, so its banners never render; a deterministic queue job id that turns a repeated click into a silent no-op; a native link to an internal path that breaks the embedded session and renders a bare `null`; an OAuth callback trusted through a cookie instead of an HMAC-signed state. **Billing: the only proof is a real click** Shopify's billing helper signals success by throwing a redirect. From an in-app form request it arrives as a 401 with a reauthorisation header, which a data request cannot follow out of the iframe. Our chassis handled it wrongly, and when we first checked, the upgrade button was dead in 14 of 17 apps. The fix that held: the action extracts the confirmation URL and returns it, and the page sends the top frame there. What makes this class dangerous is that every API-level check passes on the broken version: a direct subscription call returns a valid confirmation URL, and the build is green. The only proof we accept is a click in a real browser that lands on Shopify's approval page, followed by the plan becoming active and a working downgrade. After the rejection wave we verified 14 apps this way. Two more rules came from the same path. The test-billing flag must fail toward real billing, so an unset variable never puts production into test mode. And a fix to a billing webhook gets a second reviewer, because a bug that stops activation does not look like a broken feature. It looks like nothing. **Review, and going public** Public distribution is a one-way switch that turns on enforcement invisible before it. The Billing API refuses every call until the app is public, so we flip it early. Public apps created on or after 1 April 2026 must use expiring offline tokens; without the flag the whole Admin API returns 403, and background workers need their own refresh path. Protected customer data is declared field by field. Many of our apps read no customer data and declare level 0. An app that stores recipient names, emails, phone numbers or addresses is level 2, and answering No on encryption at rest means the app is not approved. We did not reword the answer. We encrypted the identifying columns in the application, added keyed blind indexes so phone and email lookups still work, encrypted the backups, wrote an incident-response policy, and then answered. Before every submission we run our honesty gate, which checks that each claim on the plan page, the listing and the public pages has reachable code behind it; it has its own article. The demo is a real setup walkthrough with the upgrade shown on camera, because a tour of headings fails review. **Where we were wrong** We built the first wave before the demand veto existed. Run retroactively, it said that ten of those apps were live with no installs between them, and that most sat in niches that were dead, empty, or walled by a single leader. We then read zero installs as a product or marketing problem. The cause was two switches. Fourteen approved apps were published as unlisted, so the public directory showed 6 of 20, and nine apps had no public pricing plans, so their cards showed Free and nobody could buy anything. Both took about an hour to fix. Our own fixes caused rejections. A guard we added to the login route left that route without a component, so it rendered a bare `null`; it was live in all 20 apps, and one app was suspended for 14 days. In another, a prompt line forbidding invented stock did not stop a fabricated stock number reaching a page through manual edits, and that app was suspended too. The control now sits on the write path, on the final text, just before the store mutation. TermForge's core feature had never captured a row: 72 order webhooks had arrived and all were ignored, because the code trusted the webhook payload instead of querying the order. Its charge also failed every time, because the idempotency key was 40 characters against a limit of 32, and the unit test asserted 40. Only running the money loop on a live store showed either. In September a search for the next app, by forty-one agents, returned none worth building. We turned instead to measuring the funnel of the apps we already had. **Limits** The method makes an app more correct and more honest. It does not make anyone install it, and none of these checks touches discovery. At the end of August 2026 the Shopify apps described here had no paying merchants; the outside installs we saw were app testers cycling through plans in test mode. The audit misses things too: a second honesty pass over apps already in review found six real bugs the first had missed. A green gate means known classes are absent, not that the app works. **Checklist** The niche has buyers and we can name our wedge. Three research passes are fused into one phased roadmap. The app is forked from the current chassis and its engine is pure and unit-tested. The full build is green and every flow has been driven in a browser. The audit ran with a verifier that tries to refute each finding. The upgrade reaches Shopify's approval page by a real click and the downgrade works. Expiring tokens are on, and the data declaration matches what is stored. The honesty gate is clean, and after approval the listing is visible with its plans published. ### How we build a Google Workspace add-on We build each add-on on Apps Script and CardService with the narrowest scope that does the job, then take it through consent, verification and review. Approval proves little: what kept our add-ons working was testing on a clean account, testing what they write, and counting people rather than seats. **What this is for** We build small add-ons for Google Docs, Sheets, Slides and Gmail: a side panel that does one job on the open file. By early September we had built sixteen. This is our method from the first line of code to a live listing, and the measurements after it. Most of it was learned after approval. Approval checks a listing and a video, not whether a stranger can use the product or whether it leaves their data alone. **Build: Apps Script, CardService and the narrowest scope** **1. Apps Script with CardService.** The panel is built from CardService widgets, not from HtmlService. The HTML route needs `script.container.ui`, which is a sensitive scope. The native panel is narrow and plain, and we accept that. **2. Choose the scope family before the feature.** Google has three tiers. Non-sensitive scopes, such as `documents.currentonly`, need no OAuth verification. Sensitive ones, such as `gmail.send`, need a verification round. Restricted ones, which read a whole mailbox or Drive, add an external security assessment repeated every year. The family decides the tier, not the count: one of ours carries three `currentonly` scopes and went live without verification. We read the classification in the first hour of a build. **3. Know what the narrow scope does not give you.** Under `*.currentonly` the file's content is allowed, its Drive metadata is not. `Document.getName()` throws; `Sheet.getName()`, a tab name, works. The difference is invisible in the code and fatal at runtime. **4. No backend.** Licences are signed keys checked inside the add-on and exception logging is off, so nothing about the user leaves their account. Our only view of usage is Apps Script's execution log. **Consent screen, verification and listing** Use a standard Google Cloud project; the default one Apps Script creates cannot publish. Set the consent screen to *In production*: one add-on was rejected for that alone, because on *Testing* the reviewer got *error 403: access denied*. Our submission tool now refuses while the status is Testing. Scopes must be identical in three places: the manifest, the Marketplace SDK configuration and the consent screen. The SDK keeps its own hand-typed list. The name must match in four: the listing, its web page, the consent screen and the SDK listing. For a sensitive scope the order is fixed: branding verified and published, then OAuth verification, then Marketplace submission. A verified brand lapses after 7 days unless you also press *Publish branding*, and renaming the consent screen silently resets it. In the listing, every Google mark carries (TM), including Gmail, Calendar and Drive. Keep the short description to about 100 characters. Upload more than one screenshot at 1280×800; we were flagged for having one. Do not promise one permission: the install screen shows three lines, because Google attaches email and basic profile to every Marketplace add-on. **The review** After submission an email asks for three things: test credentials, an end-to-end screen recording showing how each requested scope is used, and verification for sensitive scopes. We reply in the thread: no credentials, because there is no login; an unlisted video; how the scope is used; and a licence key for the paid features. In our first round verdicts came in 3 to 8 hours. Of 9 add-ons submitted on 18 and 19 August, 4 were approved, 2 rejected and 3 still in review. Both rejections were about form: a missing (TM) and a consent screen that did not match the listing. The rejection email arrives about 40 minutes before the console changes, so wait. While a draft is in review the SDK configuration is silently frozen: Save succeeds and the change is gone after a reload. A code fix needs no new review: a new version plus an update to the published deployment reaches users at once. Pushing code alone changes only the head version, which users never run. **The trap: installing from the store does not authorise the script** A Marketplace install grants the add-on's scopes; Apps Script separately requires the script to be authorised. While that is missing every service call is refused, and the error names the first call attempted, so it blames `DocumentApp` even when that scope is granted. Our rule that the panel must never crash made it worse: a `try/catch` swallowed the exception Google uses to draw the authorise prompt. Our fix went through four versions in 19 days. The first passed the script's authorisation URL with no account hint, so people signed into several Google accounts landed on the script file, and we received 12 requests for access to our source code from 7 users across 5 add-ons in two days. The second left the URL out and the platform refused the card: a dead end on all 16 add-ons for 17 days. The third added the user's address, which is empty outside our domain. The fourth throws nothing for Google's own scopes; the documentation says that exception is meant for third-party services. No test caught the three bad versions: each test browser had one signed-in account, and the mock drew a button regardless. The inbox caught them. We now install through the real store page on a fresh browser profile, with an account that has never seen the add-on, and check the effect on the document. That test showed a new user was never affected: the refusal hit existing grants after we changed scopes. Any scope change now requires a fresh authorisation before a fix counts as done. **Test the writes for real** Four times a bug reached users because our mock was kinder than the platform. It accepted a trigger interval under one hour, a method that exists only on another widget (a reviewer saw *Add-on error*), and `getName()`. That last one hid a dead add-on: Outline had 13 installs and zero opens in 10 days, because one unused field threw on every document. Writes were worse, because a mock with no state cannot see them. Change Case read cells with `getValues()`, which returns a formula's result, and wrote the block back with `setValues()`, replacing formulas with results. The panel said those cells were left alone; Undo reported three restored cells and restored nothing that mattered. The mocked `setValues` did nothing. In Alphabetize, 53 checks were green while the sort path had never run, because the check accepted any non-empty message, including the error. The rules now: the mock throws where the platform throws, with a method whitelist per widget; anything that writes is tested against a stateful model of the destination; assertions check the shape of the success message; and every fix is proved by mutation, putting the bug back and watching the suite fail. Verification ends in a real document, not in the panel. **Measuring usage, and seats against people** The public user count counts domain seats: when an administrator installs for an organisation, every employee is added. On 31 August our public total was 2,209; the console's *Individual End User Installs* chart, which counts people who installed it themselves, gave 434. Change Case showed 1,892 users: 2 organisations, 1,875 seats and 32 people. Our ranking was upside down. Usage comes from the Apps Script executions API, which keeps about 10 days of history, not 30, so our 30-day label turned ‘not this week’ into ‘never’. Functions the report did not recognise looked like buttons nobody pressed. The tool now prints the window it covered and reads button handlers from the published code. We split ‘installs but no use’ into three cases: never opened, opened but nothing done, used but not paid. Only the third is about the offer. And before trusting a zero, prove the instrument can see a one: open the add-on yourself and check your execution appears. **Where we were wrong** We first concluded that every published add-on was unusable for everyone. The clean-account test and a five-star review refuted it: the failure hit a subset. We reported the account-hint fix as done when it existed only on our machine. The next day brought six more access requests from five people on four add-ons, none from the one add-on we had actually deployed. Nothing is fixed until the served code is checked. Our only one-star review said an add-on ‘doesn't even work’. The empty panel told the person to press Sort; the only button on screen was Refresh, because a side panel cannot see a selection made after it opened. They were right. The main action now exists in the empty state. We also declared an add-on invisible after a correct install. It sat in the *More Add-ons* overflow; one screenshot settled it. **Limits, and a checklist** This method gets an add-on approved and keeps it honest. It does not make people use it: why many installs never become use is still open for us. Consoles also change under automation without notice, so every saved setting is confirmed by a reload. **Checklist.** Scope tier read in the first hour. No file metadata under `currentonly`. Consent screen in production, branding verified and published. Scopes identical in three places, name in four. (TM) on every Google mark. Clean-profile install with an account that has never seen the add-on. Stateful mocks that throw like the platform, mutation-tested. Fix checked on the served version. Seats and people read separately, and every zero checked against your own execution. ### How we build and ship Atlassian Forge apps We built 25 Jira and Confluence apps on a few shared chassis and submitted them together. Four are public; the other 21 were rejected and are stuck behind a listing state only Atlassian can clear, because we skipped the step that would have shown us the wall: taking one app all the way through review first. **What this covers** Forge is Atlassian's platform for Jira and Confluence Cloud apps. Atlassian hosts the code and the app runs inside the product. In August 2026 we built 25 Forge apps, 9 for Jira and 16 for Confluence, and submitted all of them to the Atlassian Marketplace. This is the method we use now, including the parts we only learned by getting them wrong. On 25 September 2026 four apps were public on the Marketplace: Epic Roll-up, SprintEstimate, Templatize and Recurring Issues, each checked on its public page. The other 21 were rejected on 8 September and cannot be resubmitted until Atlassian clears a listing state on its side; on 29 September its support team described the case as an edge case it was working on. On 21 September the public data showed no installs for any of our apps. **Listed and used are different things, and so far we can show only the first.** **One chassis, many apps** We do not design 25 apps. We design a chassis and fill it. A Jira app is a manifest, a resolver (the backend function the interface calls) and a UI Kit front end. The reference is SprintEstimate, a planning-poker board, tested end to end before anything was copied from it. Confluence macros that call a language model use a second chassis built on Custom UI; eleven of those macros share it, and we confirm that their licence check is byte-identical across all eleven with a hash, not by reading. Tests run against the deployed app. We drive the live app through its real interface and then read the side effect at the source, through the Jira REST API: the field value, the number of issues created, the author of a comment. That caught a Save button that sent an empty value instead of the average it displayed, and it showed us how a test can lie. A Confluence macro's live preview uses the same CSS classes as the saved macro, so a test that looked for those classes passed while no user could save any of the 10 macros we had shipped. A save is now proven by the configuration dialog closing and the macro rendering without its editor fields. **Licence gating, proven by effect** A paid Forge app reads its licence from the request context and withholds the paid feature when there is none. Ours read a field that does not exist on the backend. The front-end object calls it `active`; the object a resolver receives calls it `isActive`, and the resolver framework replaces whatever the front end sent. In all 20 monetised apps, across 34 places in the code, the paid check was therefore always false: a paying customer would have received the free tier. The fix reads either name. Absent is not the same as inactive. Forge sends no licence object for free editions and non-production installs, so treating a missing licence as unpaid locks out people who are entitled to be there. We use three states: paid, lapsed and unknown, and unknown never blocks. Reading the code was not accepted as proof. `forge install` accepts a licence value such as active or inactive on a development install, so the paid path can be exercised before approval and without any payment. On our issue-cloning app an inactive licence created exactly 1 issue and an active one created exactly 3. We counted them through the API, not from the success message. The same test found one app whose limit existed only in the browser: the backend sent everything and the front end hid the excess, so anyone with developer tools had the paid tier. The limit is now applied in the resolver. **Resolvers run as the user** Atlassian scans every submitted version with a static analyser and opens a security ticket with a deadline. On 31 August it rejected two of our apps for a missing authorisation check, at severity P2. The cause was a class of bug, not two bugs. Every product call in the resolver used `api.asApp()`, so anyone who could open the panel acted with the app's permissions: one app could show an issue's change history to a user not allowed to see the issue, another could create issues where the user had no permission to create them. The scanner found two; a grep across the fleet found five. The rule now: resolvers call `api.asUser()`, so the product enforces the caller's own permissions, and `asApp()` is allowed only in scheduled triggers, where there is no caller. The helper defaults to the user, so a new call site is safe by omission. Every write is also gated with Forge's authorise API and fails closed. We verified the fix by effect: in each app the issue or comment it created had the invoking user as its author, not the app. On 1 September, after resubmission, Atlassian's scanner rescanned both apps and marked the tickets patched. It sent no email when it did. **The review happens in a ticket** Every submission opens a ticket in Atlassian's service desk, announced by a single email. The reviewer's questions live in that ticket: test credentials for any third-party system the app integrates with, the reason for each API scope, and every remote host the app sends data to. If nobody answers, the review expires with the reason *Timeout*, a word we could not find anywhere in Atlassian's documentation. We learned this late. On 27 August, 12 of 20 listings had been sitting rejected for Timeout for over two weeks. The emails came from a domain we were not searching and the mail client filed them away from the inbox. Nothing was wrong with the apps; we had not answered. Now we capture the ticket at submission, confirm we can open it, and answer the three questions the same day. A watchdog runs twice a day and reads the platform's own state as well as the mail, because a status can change with no email at all. **The Sharing trap** The reviewer has to install the app. A Forge app that has a licence in its manifest, or has been submitted for listing, cannot be shared through an installation link, and a listing cannot be installed before it is approved. We built the unlicensed copies Atlassian's documentation prescribes, 25 of them; the reviewer then told us testing happens only on the submitted app. The real cause took weeks to find. The Marketplace interface refuses to list an app whose distribution status in the developer console is not *Sharing*. We had created 21 listings through the console's own GraphQL calls, where that check does not exist. The 4 apps we had set to Sharing before listing went through review and are the four that are public now. The 21 ended up in a state the console cannot represent: the Sharing control stayed disabled after rejection, and removing the licence did not release it. Atlassian's support told us to publish through the interface, one app at a time, so that every validation runs. **When an API accepts something the form refuses, that is not a shortcut, it is a debt.** Set distribution to Sharing first, then create the listing in the interface. **Where we were wrong: pilot one before the fleet** The expensive error was not a bug. We submitted 25 apps at once without taking one of them through review to approval. A pilot would have hit the Sharing wall on the first app, in the first week, with 24 still unbuilt. Instead we multiplied a deadlock 25 times. A smaller error of the same kind: we once reported a breakthrough after reading the page's own help text (*Select the Sharing option, fill in the app details, and select Save changes*) as if a form had opened. We now prove a blocked control by removing its disabled attribute, clicking for real and counting dialogs, inputs, network requests and the length of the page text. On the locked app every count stayed at zero and the text was identical to the character; on an unlicensed copy the same click opened the form. The contrast is the proof. When we did pilot, on a single listing that Atlassian removed so we could recreate it, it exposed a further layer, an app key that stayed reserved after the listing was archived. We found it on one app instead of on 21. **Limits** The method lowers the chance of a rejection or a broken paid path. It does not produce customers: an approved listing that nobody is searching for is a shelf, not an audience. Our licence tests cover our code, not Atlassian's billing, and a real Marketplace purchase has not yet been observed end to end. The review steps described here are the ones we met in 2026. **Checklist** **1. Reference app.** Build one app on the chassis and test it live, reading side effects at the source, before copying it. **2. Identity.** No `asApp()` outside scheduled triggers; every write gated and failing closed. **3. Licence.** Read both field names, treat a missing licence as unknown, and effect-test active and inactive installs. **4. Sharing first.** Set distribution to Sharing, then create the listing through the interface. **5. The ticket.** Capture it at submission, answer the reviewer's three questions the same day, and watch platform state, not only mail. **6. Pilot.** One app goes all the way to approval before the rest follow. ### How we build a monday.com app We built one monday.com app, a board view that writes a short AI summary of a board, and submitted it in August 2026. It was rejected in September for overlapping apps that already exist. The build method held up; the step we skipped was asking monday, before writing code, whether the concept would clear its duplication policy. **What this is, and where it stands** monday.com lets developers add views, widgets and integrations to its boards and sell them through its marketplace. We built one app there, Recap: a board view that reads a board and writes a plain-language summary of what moved, what is blocked and what is due, exportable as a PDF. We submitted it on 16 August 2026. On 28 September monday's reviewer rejected it, saying its functionality overlapped significantly with solutions already available and that the AI feature did not add meaningful value. It is not listed, and we are not building a second monday app. What follows is the method we used, which mostly worked, and the decision we got wrong, which did not. **Read the form before the docs** The binding requirements are spread across the developer documentation, the marketplace listing terms and the submission form, and the form is the strictest and the most current of the three. Three of its rules change what you build. Since 13 August 2026 it states that monday only accepts apps with AI capabilities, a rule we found in no developer document. Billing must go through monday's native monetisation. No-code and "vibe-coded" apps are not eligible. A fourth rule decides whether to build at all. monday's quality and duplication policy bars a new app whose main purpose duplicates an existing integration, even a better one, and says that slight interface tweaks and minor feature additions are not new value. We read it as a list of forbidden third-party connectors. The rejection showed it also covers functionality. Demand data is easy to get. The marketplace is fed by public JSON with exact install counts per app; join the files on `app_id`, never on `id`, which silently mis-joins. Split by launch year, the 2026 cohort of 235 apps had a median of 5 installs when we measured it in August. We recorded that and built anyway, as a single bet. **Building the board view** The app is a board view: a front end in an iframe using monday's SDK, plus a backend that verifies monday's signed session token and asks a language model for one paragraph. The order matters: create the app, add a board-view feature, point it at your build, grant scopes on a draft version, promote that version to live, and only then install. An install made while the version was still a draft does not bring the view into the board. The traps we hit. The CLI command that creates an app crashes without a terminal but still creates the app, so list your apps before retrying or you get duplicates. A promoted version is locked, and edits to it silently revert until you create a new version. When every API call from the view fails with *Graphql validation errors*, including the simplest possible query, the app has no scopes; your query is fine. And the install consent screen is the only honest record of what you ask for: ours requested profile access our own paperwork said we did not need, so we removed it. We tested the renderer outside monday by feeding it a real board as JSON. That caught a status called "Fixed" that our code did not count as done, so the report showed zero completed items where 3 were resolved. Invented test data, where everything finished is called "Done", would have hidden it. **Let monday host it** monday can host app code itself, and its own form says that doing so lightens the security review. We hosted Recap ourselves and paid for it. The signed session token, which monday puts in the view's URL, was being written into our web server's access logs; we had no defined retention period for those logs; and our secrets had to be described in the privacy answers. None of that exists when monday hosts the code. Decide this when you scaffold the app, not after the security questions arrive. **Audit before filing** Before filing we ran an adversarial audit with 14 agents over the code, the listing text, the published legal pages and monday's documentation. It returned 45 raw findings; 7 survived refutation, and the verdict was "not ready". The classes worth checking on any app: claims about competitors that are not true (we had described the incumbents as template-based tools, when several were zero-setup board views and one already wrote an AI recap, and that claim sat in the very field monday uses to judge duplication); privacy text that promises more than the code does; a status classifier that counted "Unpaid" and "Not approved" as completed; a third theme, black, besides light and dark; and declaring mobile support for a PDF button that does nothing in the mobile app. **Filing** The long-text fields stop at 2,000 characters and turn the counter red instead of truncating, so an over-long answer looks fine until it blocks the form. One of ours had to go from 2,672 characters to 1,994. Some steps belong to the account owner and cannot be automated: accepting the developer terms, a binding support commitment of 2 business days, and a signature image. We filed with the wrong pricing answer and nearly withdrew over it. The documentation says initial pricing is reviewed inside the running review, so it was correctable there; the one choice that cannot be changed later is the pricing model. monday's first response is due within 72 business hours, and the rest of the review runs on a shared board. **Where we were wrong** Our own assessment from August said not to build for monday at all, with one exception, Recap, and only after getting written pre-clearance of the concept against the duplication policy: a step that needed no code and about 72 business hours. We filed the app the same day and never asked. We learned the answer from the rejection instead. The rejection itself arrived by email on 28 September and sat unread for 7 days. Our mail watchdog searched only for the Atlassian review sender, and we reported "no new mail" when what we had checked was one sender. The check now starts with a broad search and states which channels it covered. The competitor claim above belongs here too. We caught it before filing, but the reflex to describe neighbours as weaker than they are is exactly what a duplication review tests. **Limits and checklist** The build steps held: the view worked on real boards and passed our own audit. That says nothing about whether monday wants the app or whether anyone would install it, and both questions are answered before the first line of code. **1. Pre-clearance.** Ask monday in writing whether the concept clears the duplication policy before building. **2. The form.** Read the live submission form, not last month's notes; plan a real AI feature from day one. **3. Hosting.** Default to monday's hosting unless there is a specific reason not to. **4. Scopes.** Read them on the consent screen and remove any you do not use. **5. Audit.** Check competitor claims, privacy text, status logic, themes and mobile before filing. ### How we build an iPhone app We test demand with our own App Store scanner before writing code, keep the engine in a package that can be verified without Xcode, and treat a clean install on the simulator as our first reviewer. Our worked example, Null Field, is built and ready to submit, but it has not been submitted. **What this covers, and where Null Field stands** This is how we take an iPhone app from an idea to a build we can submit. The worked example is Null Field, a paranormal entertainment app that turns small changes in the phone's magnetometer into words, and says plainly that the words come from measured sensor variation. The status, stated plainly: Null Field is not on the App Store. We have a validated Release archive, a verification suite of 92 checks that passes, a bilingual interface, the listing and reviewer notes, the privacy answers, 8 screenshots at 1320×2868, and live privacy and support pages. What is missing is the paid Apple Developer Program membership, which we postponed on purpose. Until then the app runs on our own phone under a free signing profile that expires after 7 days, so a script reinstalls it every week. The name came out of the process. The working title was an active registered trademark of an existing app, and Apple rejects lookalikes under Guideline 4.1. We chose a name that describes what the engine does, a baseline and deviations from it, and promises nothing we do not measure. **Step one: a demand test that measures flow, not stock** Before any code, we scan the App Store ourselves. Our scanner uses Apple's public endpoints without authentication: the search and lookup APIs for metadata, the per-country review feed for dated review text, and the product page for in-app purchase prices, which the API does not return. The rule that matters is flow over stock. A lifetime rating count only goes up, so it tells you how big a niche once was. We derive a monthly rate from the dated reviews instead. One niche had a leader with 3,774 ratings, which looked beatable, and produced 10 reviews a month across every app in it. It was dead. Apple's search is broad-match and sorted by popularity, so a query for a niche tool returns general-purpose apps with over a million ratings; an app only counts if every word of the query appears in its name or description. And complaint volume is not demand: across the 34 niches we profiled, the dominant complaint was price, at 25% to 49% of negative reviews in every one. The loudest reviewers are disproportionately the people who do not pay. Three things disqualify a niche for us: a free canonical tool, whether preinstalled or funded by a university or a community; a top complaint that cannot be fixed, such as metal detectors, where the magnetometer cannot see gold, silver or copper; and Guideline 4.3, which rejects near-duplicate apps from the same developer. Of the niches we ranked, paranormal had the highest measured flow, 8,539 reviews a month. **Step two: an engine you can verify without Xcode** The machine we started on had no Xcode, only the command-line tools. So the engine lives in a Swift package that depends only on Foundation and CryptoKit, with no UIKit and no CoreMotion. It compiles and runs on a Mac without Xcode, and the iOS app is a thin layer that feeds it sensor data. The command-line tools ship neither XCTest nor Swift Testing, so the tests are an executable target that prints a comparison table. The output can be read, not just ticked. We checked our randomness estimators against the worked examples in NIST SP 800-90B, which reproduce exactly, and the suite caught 4 real bugs that reading the code had missed, including an estimator that scored a source 0 because it was too good. Judge speed only in release builds: one assessment took 1,266 ms in debug and 10 ms in release. **Step three: build for the device, then check the artefact** "BUILD SUCCEEDED" does not mean installable. With a hand-written Info.plist, Xcode stops injecting the bundle identifier, the executable name and several other keys. The build is clean and the app will not install. A small script now checks the built bundle for those keys before anything else happens. The first device build covered 5,203 lines that had never been compiled for iOS and produced 3 errors, all outside the verified engine. Everything the harness covered worked first time, which is the argument for the package split. When code must exist only in simulator builds, we prove it is excluded with a poison test: a deliberate compile error inside the block must break the simulator build and leave the device build untouched. Searching the binary for strings proves nothing, because the code lives in a framework. **Step four: a clean install on the simulator is the first reviewer** An iPhone 16 Pro Max simulator produces 1320×2868 screenshots natively, the 6.9-inch size App Store Connect asks for, so checking the layout at final resolution does not need the phone. The simulator found a bug the phone could not show. The app asked for the microphone at launch, on a screen that records nothing. On our phone the permission had been granted months earlier, so the prompt never appeared. On a clean install it did, and it would have failed Guideline 5.1.1, which expects permission to be requested at the point of use. The request now sits on the record button. The simulator has no magnetometer, so the app would wait for calibration forever. We did not fake data. The app can replay a recording made on our real phone at 100 Hz, so the words in the screenshots come from the real engine fed by a real measurement. Only the transport changes. A structured readiness audit then confirmed 7 of the 36 issues it raised. The English mode was incomplete, with 3 of 5 tabs still in Romanian, and the app always started in Romanian, so an English-speaking reviewer would have read our disclaimer in a language they do not speak. The recording mode also captured the app's own synthetic voice. Each fix now has a permanent check, and before submission the plan is a release review covering security, the privacy manifest, UX and distribution. **Step five: measure on hardware before you trust a threshold** Every trigger threshold was first set on simulation. So we built a field probe into the app: leave the phone on a table for 10 minutes and it reports the real sample rate, the noise, and how many false triggers there would have been. On our phone the magnetometer ran at 100.7 Hz with noise of 0.148 µT, against an assumed 0.15. The simulation held. What simulation could not have caught: the raw gyroscope has a bias of 0.0168 rad/s at rest, above the textbook threshold for a phone that is not moving. A motion gate built on raw data would never have opened. The fix is the bias-corrected rotation rate. The probe also settled a listing question. A 1600 W hair dryer was visible only with the phone touching it; at 2 to 3 cm the effect disappeared into the noise. So the listing does not say "EMF". **Where we were wrong** Our first trigger fired on a statistical score alone. On a simulated phone lying still it produced 166 words in 5 minutes, more than the 151 it produced when something was actually happening. A z-score divides by the spread it has just measured, so the sensor was talking to itself. The detector now needs three things: an absolute floor in physical units, a threshold derived from a false-alarm budget rather than picked by hand, and persistence over 5 consecutive samples. In one session we reported "64/64 checks pass" several times. One check had a ceiling of 250 while the scenario produced between 249 and 256, so it failed about 6 runs in 10, because the harness used an unseeded generator. Every generator is now seeded, and checks run on several fixed seeds and print their spread. The Romanian word bank had been written without diacritics, and it was delivering different words: without accents, the word for "face" reads as "girl", and "river" and "bad" become the same word. We corrected 528 entries. **What the method does not guarantee** It does not tell us that Null Field will pass review or sell. It has never been submitted, so we have no review outcome to report. The scanner cannot see revenue or downloads, because Apple does not publish them, and reviews are somewhere between 0.3% and 5% of installs, so any estimate of installs spans a band of about 16 times. We implement 5 of the 10 NIST estimators, so the entropy figure the app shows is an upper bound, and the app says so. **Checklist** **Before code.** Scan for monthly flow, not lifetime ratings; filter search results by membership; discount price complaints; rule out free canonical tools and unfixable complaints; check the name against existing trademarks and Guideline 4.1. **While building.** Keep the engine in a package with a readable, seeded verification suite; judge speed in release builds; check the built bundle, not the exit code. **Before submission.** Run a clean install on the simulator; take screenshots from replayed real data; start the app in each language and look; ask for permissions at the point of use; confirm thresholds on hardware; keep the privacy and support URLs live and stable; run the release review. ### How we build an Android app for Google Play We read Google Play's gates and measure demand before writing code, build a native Kotlin and Compose app, then look at it running: our worst defects were visible only on screen. Our first app is built but not published, and our own research says organic Play distribution will not carry it. **What this covers, and where our first app stands** This is how we built our first native Android app for Google Play: a practice app for the Romanian driving-theory exam, written in Kotlin with Jetpack Compose and sold as a one-time purchase. The method is packaged as a reusable skill. Account set-up, upload and the listing are covered by a separate launch kit our team already had. The status: the app is built, a signed release bundle installs and runs on the emulator, and its privacy policy is published. It is not on Google Play. We chose an organisation developer account, which required a D-U-N-S number. At our last record the account existed, and identity, website and phone verification had to be completed before Google would let us create an app in it. The honest context: after a structured research round in August, we concluded that organic Play distribution would not bring a small developer meaningful volume. We built this app anyway, as a deliberate bet on the one niche that passed our tests. **Step one: read the gates before the ideas** We read the platform's rules before generating ideas. Four gates shaped everything else. **1. Account type.** A personal account created after 13 November 2023 needs 12 testers opted in continuously for 14 days in closed testing, plus a production-access questionnaire, for every app. Internal testers do not count and become ineligible for closed testing, and a release left in draft does not start the clock. Personal accounts are also barred from finance, health, VPN and government apps, and a monetised personal account publishes the owner's home address. Google labels the tester step "personal accounts only" but nowhere states outright that organisations are exempt, so we treat the exemption as unverified. **2. Irreversible choices.** The account type cannot be converted later, the type of a payments profile can never be changed, and the package name is fixed after the first upload. We fixed the package name before building any bundle and check it inside the built file, not in the build script. **3. Repetitive content.** Several apps on one account are allowed; near-identical ones are not, and reusing descriptions or screenshots across apps is named as a violation. Opening more accounts does not isolate the risk. **4. Accounts expire.** Developer accounts can be closed for inactivity, and the warnings go to whichever inbox was registered, read or not. Someone now owns those notifications. **Step two: measure demand with our own scanner** Google Play's store pages can be read without an API key, and they carry more than the public download bucket: an exact download count, the star histogram, the in-app purchase range and the date of the last update. Our scanner reads them per locale. Two traps: the launch date is the first availability in the country you query, not the global launch, and ratings are per country. Scanning the Romanian market with US settings is worthless. A niche counts as alive only if at least 3 apps have 100 or more reviews and make up at least 25% of the sample. The more important gate is rank yield: not "can we rank?" but "what does position N pay?" In one day it eliminated 43 of 45 candidate niches. A third gate is search volume: a vertical with no apps and no searches is not a gap; nobody searches because nobody buys. We scanned 8 Romanian niches. Only 3 passed the demand test, and driving theory was the only one with proven payers, 11 of 14 apps selling in-app purchases, and 368,000 searches a month. **Step three: the native chassis** Signing is written straight into the Gradle build file, with a guard so that a machine without the secret still builds. The upload key goes into our secrets store, and "saved" is not "recoverable": we rebuild the key from the secret and compare checksums immediately, because a lost upload key means no further updates to that package, ever. For a one-time purchase, every purchase is acknowledged, since Play refunds unacknowledged purchases after 3 days, and purchases are restored automatically at start-up, not only from a button; otherwise someone who reinstalls pays twice. From 31 August 2026 new apps must use Play Billing 8 or later. Its Kotlin extension would not compile with our Kotlin version, and the error looked like a mistake in our code, so we use the plain Java artefact. The reduced service-fee tier is not automatic either: it has to be enrolled in by hand in the console. Two smaller rules: minified builds need their mapping file uploaded, or the first real crash report is unreadable; and we check the file we ship, not the build log, because Gradle once reported success and shipped the old binary. **Step four: run the app and look at it** We had built 506 questions, 51 road signs and 6 scenes without ever seeing the app run. The first emulator session found 6 defects. The worst cost money directly: free exams were counted when an exam finished, so pressing Back or abandoning an exam bypassed the paywall indefinitely. The rule now is to consume a limited resource at the start, never on the success path. We drive the emulator by text, not coordinates. A small script reads the screen's accessibility tree and taps "the button that says X", because in Compose a longer question moves the buttons and a tap on coordinates read off a screenshot fails silently the second time. **Step five: content you can trust** The authority that runs the exam refused, in writing, to let us reuse its question bank, so we write our own questions from the public law. Generating legal content with a model failed in an instructive way: 352 draft questions contained 29 critical errors, 14 of which taught the opposite of the law. A legal-basis field the generator filled in was wrong in 16 of the 24 cases checked, so we deleted it: a provenance field nobody can validate automatically stops human checking exactly where it is needed. Validation by a human specialist remains mandatory before publication. A quality gate with 5 heuristics runs after every import and fails the build: umbrella answers, the most frequent correct position, "the longest answer is correct", length bias per chapter in both directions, and runs of keys on one position. The first three came from asking how people cheat at the real exam. The longest answer was correct 54% of the time before the fix and 32% after; the gate then caught 20 questions in a row with the answer on A. For illustrations, 220 of the 232 available road-sign drawings carried share-alike licences, which would have contaminated a paid product. The sign designs themselves are set by law, so we redraw them with a deterministic renderer rather than generative AI, and look at a contact sheet after every batch. Looking caught 8 of 18 scenes wrong in one batch, including a roundabout with a negative radius that was still a valid SVG. The paywall sits on the differentiator. The official simulator is free by law and the market leader is free with no purchases, so the free tier is the complete experience at small volume, and what you pay for is the explanations. A disclaimer of non-affiliation with the authority sits on the main screen. **Where we were wrong, and the verdict on organic Play** Visibility on Play is real: an app 4 days old with zero installs ranked in the top 24. Volume is not. On queries where a new app can rank, 45% to 90% of the top 20 had zero reviews, and one app that had ranked first for 31 months collected 32 to 161 installs a month. Our first recommendation rested on two recent entrants that appeared to grow organically through their own websites. Public ad transparency records showed that both had been buying install campaigns throughout. In the same niche and period, an app buying ads gained 19,564 installs a month and an organic one 1,904. Without that case, none of about 45 niches survived. A later search for a second Romanian app measured 56 clusters: the best flow in the store was 3,500 installs a month, against the 6,600 to 11,000 our target required. Smaller errors: we described a free promotional channel that in fact requires traction far beyond ours. **Limits and checklist** Nothing here shows that the app will pass review, get installs or sell. It has not been reviewed. The exact download count our scanner reads is an undocumented internal field, good for an order of magnitude, not as a time series. Our content verifiers are models, not lawyers. **Checklist.** Read the gates first; choose package name and account type deliberately; check rank yield and search volume before code; prove the upload key is recoverable; acknowledge and auto-restore purchases; check the shipped file; run the app and look; consume free uses at the start; gate any graded question bank; put the paywall where the free alternative stops; write down in advance the numbers that would make you stop. ### Recording demo screencasts that pass marketplace review We record the page, not the screen, in a dedicated browser, with steps addressed by selector and captions burned in. Each beat of the film shows one thing the requested permission is used for, so the reviewer can see the app do what the listing claims. Most of our rejected takes came from the surface and from leftover state, not from the app. **What this is for** Marketplace reviewers ask for video. For a Google Workspace add-on, the review email asks for an end-to-end screen recording that shows how each requested permission is used. OAuth verification for a sensitive scope asks for a YouTube link. Shopify asks for a walkthrough in English or with English subtitles. This is how we record those videos so a reviewer can follow them without help, and the takes we had to throw away. The point to hold on to: a reviewer does not grade the video. They use it to decide whether the app does what the listing and the permission request claim. A video that hides a weakness only moves the rejection later. **Record the page, in a browser nobody else is using** **1. Record the page, not the screen.** We record with Playwright's page screencast over the Chrome DevTools Protocol. It captures only the page's pixels, runs in the background, needs no screen-recording permission and does not take over the machine. Capturing the whole display is a fallback only, and only after a test screenshot shows the right window in front: once, window activation failed silently and the recording caught whatever was on the screen, including private windows. That file was deleted at once. **2. Use a dedicated browser.** Filming runs in its own Chrome instance, with its own profile and debugging port, separate from any browser a person is working in. We once started a recording agent on a browser that was in active use, and stopped it before it recorded anything. **3. Address elements by selector.** Our first pipeline calibrated screen coordinates by hand, about 80% of the setup time, and broke whenever the layout moved. Now each step names an element by role, label or text, and its position is read live just before the click. Setting up a new app went from about a day of calibration to 30 to 60 minutes. **4. No drawn cursor by default.** A synthetic pointer looked mechanical on video. Without it, clicks land and the interface reacts, like a recording where the cursor was not captured. **A walkthrough, not a tour** A Shopify screencast of ours, for MergeGuard, was rejected under requirement 4.5.3: the reviewer needed clear step-by-step instructions to set up and test the core features. Our cut had pointed at headings and set nothing up. What passed followed the merchant's real setup order and exercised every core feature on camera, narrated as numbered steps. For a Workspace add-on the film is short, about 45 to 85 seconds, silent, with captions burned in. One beat for each thing the permission is used for, in the order a person works: the document as it is; the selection, which shows that the add-on touches only what was selected; the panel opening and reporting what it read; the preview, if there is one; the action that writes, with its confirmation; Undo, if there is one; and a line saying the licence is checked locally, with no server. Seven beats, each one something the reviewer can see happen. **Captions are part of the video** Our recordings have no voice, so the captions carry the explanation, and the reviewer should not have to switch them on. Neither obvious route worked for us: uploading caption tracks needed a YouTube permission our upload credential did not have, and our build of ffmpeg had no subtitle filter. What works is rendering each caption as a transparent image and overlaying it for its time window, about 32 overlays for a two-minute demo. The script's narration lines are split into sentences and each gets a duration weighted by its length. A caption is never cut to fit three lines: that silently dropped half a sentence. **Proving least privilege on camera** For OAuth verification the video has to show why each sensitive scope is needed. Our Gmail add-on Cards was refused because the video did not show *why narrower permissions cannot be used*. No better video could fix that, because a narrower path existed in the code. We changed the code to read only the message header when the panel opens and the body only when the person acts, and filmed again. To make the narrower reading visible we put a witness in the data: a test email whose body contains a marker sentence that is not in the subject. When the panel opens, the marker is visible in the message and absent from the panel, in the same frame. After the action, it appears in the result. Without the marker, ‘we did not read the body’ and ‘we read it and did not show it’ look the same. Two more things about verification videos. The project owner never sees Google's unverified-app screen, which the video is supposed to show; installing from the public store page with an outside account does show it, but only works once the add-on is public. And the written justifications we first drafted from memory contained claims the code contradicted, such as ‘nothing leaves the user's account’ for an add-on that sends email. An adversarial read of the code found them before submission. **Traps of the Docs, Sheets and Slides surface** We filmed four add-ons in one session, across three surfaces: Docs, Sheets and Slides. Each trap below cost a take. Every CardService control rebuilds the card on the server, so a click sent too soon after another lands on a detached node: it ‘succeeds’ and nothing happens. That produced three false reports of a broken button. We wait for the next control to be visible, then pause. Grids of icons are also exposed as a single container button whose name joins every label, so selectors on grid cells must be exact. In Docs, extending a selection with Shift and arrow keys did not work through automation; a real mouse drag did. In Sheets, the name box is safer than dragging, which tends to grab the column header and select the whole column, contradicting the ‘only what you selected’ story. Search fields in the panel fire on Enter and arrive prefilled with the last saved search, so typing adds to it: we once filmed a search for ‘rocketrocketrocket’. Fill the field, then press Enter. In Slides the side panel is hidden until *Show side panel* is pressed. In Sheets, the Gemini panel can open in the add-on's place. In Docs, the built-in outline panel has to be collapsed, or the film appears to demonstrate a feature Docs already has. **State between takes, and the reviewer who filmed us back** A dry run that clicks also changes the document, so we recreate the file between the dry run and the take. A dry run that does not interact cannot validate selectors: we used the hint text under a field instead of its accessible label and found out mid-take. Add-on settings persist between takes, so we write state in full rather than adding to it: once, entries left from old tests made our optimisation add-on refuse correctly on camera, a failure we had manufactured. And a checkbox's `value` is not its state. We narrated that whole numbers were on while the box was off, and the result on screen, 17.78 batches, contradicted the caption. Before filming we activate a licence key, or the paid features never appear. Before taking listing screenshots we remove it again: one screenshot showed a test address as an active Pro licence. For Gmail screenshots, the guard checks structure, not words: every visible row must be one of our synthetic test messages. Then a reviewer sent us a video of their own, a 1:07 clip. Its link was only in the raw source of the email, not in the rendered text. They had rebuilt the example from our demo but typed numbers where our sheet had formulas. The add-on diagnosed it correctly and left the sheet alone, and offered one button: Back. Our video was accurate; the product required a correctly built file before the first success. We added a button that builds a working example in a new sheet. **Limits, and a checklist** A good recording gets a fair review. It does not prove the app works for people whose files and accounts differ from ours, and the reviewer's own attempt is a better test than our film. Some traps above belong to a particular week of Google's interface and will change. **Checklist.** Page capture in a dedicated browser. Selectors read live, dry run first, document recreated. One beat per use of each scope; a walkthrough, not a tour. Captions burned in, never truncated. A witness in the data when least privilege is the question. Licence on for filming, off for screenshots. Look at a frame before uploading, and read any video the reviewer sends back. ### How to answer privacy, GDPR and security questionnaires for marketplace apps Every app marketplace asks what data the app touches, where it goes, who else processes it and what protects it. We answer every form from one data map per app, back each Yes with a public page or a contract, and treat a No that blocks approval as a reason to build, not to reword. **What the questionnaires are actually asking** To answer a marketplace privacy or security questionnaire, first write down for each app what end-user data it reads, where it stores that data, and what leaves the platform and to whom. Then answer every form from that one record and publish the documents it implies: a privacy policy, a security page and, if personal data reaches you, a data processing agreement. The forms differ by platform, but all of them test whether your declarations match what the code does. A **data processing agreement** (DPA) is the contract that GDPR Article 28 requires between a *controller*, the customer who decides why personal data is processed, and a *processor*, the vendor who processes it on the customer's behalf. Without one the customer is in breach, so an EU buyer's legal team will not approve an install without it. **Step one: a data map per app, sorted into groups** **1. Map what the code does, not what you remember.** For each app we list the scopes it requests, what it stores and where, and every outbound request, taken from the manifest and the code. A logo URL in a manifest is an external request, and a server that caches to disk stores data. **2. Sort the apps into groups that share an answer set.** Our Forge apps fall into three groups: apps that store nothing and send nothing out, apps that store data only in the platform's own storage, and AI apps that send the user's prompt to a model provider. The AI group answers Yes to processing end-user data outside the platform, because a prompt a user types is end-user content; redefining it to get a cleaner form would be dishonest. **3. Name the processing chain.** The customer is the controller, our company is the processor, and the platform and the AI model provider are sub-processors. Transfers outside the European Economic Area (EEA) are covered by the model provider's DPA, which incorporates the EU Standard Contractual Clauses (Module 2, Commission Implementing Decision (EU) 2021/914) into its commercial terms. That existing contract is what makes a Yes to the transfer question true. **A self-executing DPA for every app** We publish one DPA page per app, in two variants: the AI apps name the model provider as a sub-processor; the apps with no outbound traffic state that no personal data reaches us at all. Each DPA is *self-executing*: it is incorporated into the app's terms of service and takes effect on install, with no signature, plus an offer to counter-sign on request. A DPA is a list of obligations, so we wrote only ones we can keep: process data only on the customer's documented instructions; notify a personal data breach within 48 hours of becoming aware of it; delete or return data within 30 days of uninstall or a written request; give 30 days' notice before changing a sub-processor, with a right to object; accept an audit at most once a year, at the customer's cost; and assist with data-subject requests and with Articles 32 to 36. The pages also say what we do not have: no SOC 2, no ISO 27001, no independent penetration test, and no data protection officer, which Article 37 does not require in our case. A reviewer reads a plain statement of absence more easily than vague wording. **Atlassian: the Privacy & Security tab and two more forms** Each Atlassian Marketplace listing has a Privacy & Security tab. For an AI app, answering Yes to processing outside Atlassian opens four hidden required fields, each revealed only by the answer before it: the types of data accessed, privacy-enhancing techniques, whether data leaves the EEA, and which transfer mechanism applies. Declaring yourself a processor adds the end-user data types and whether you offer a DPA. Once an app declares processing outside Atlassian, a public security policy link becomes required. Our security page per app states where the app runs, its scopes, its authorization model, what leaves Atlassian, and how to report a vulnerability, with a commitment to acknowledge within 2 business days and to fix, or give a plan, within 10. Atlassian has three security forms: this tab, the Forge security questionnaire and a partner questionnaire about the company. The partner one opens from a button on an app's review ticket. Across the two questionnaires we answered 33 questions. **Shopify: protected customer data levels** Shopify sorts apps by **protected customer data** (PCD). An app that uses customer data but not the identifying fields is Level 1; an app that stores a customer's name, email, phone or address is Level 2. Most of our Shopify apps are level 0 or 1 and passed easily; that precedent does not transfer to an app that stores a parcel recipient. In the PCD request you first declare the fields you persist; over-declaring fails as surely as under-declaring, so check the model and every ingest path, including webhooks. Level 2 then brings 16 data-protection questions, which are legal attestations. Encryption at rest, encrypted backups and an incident-response policy are required, and a No means the app cannot ship. For our Level 2 app the honest route was engineering. We encrypted the identifying columns at application level with AES-256-GCM and added *blind indexes*, keyed HMAC values of the normalised phone and email, so exact-match search still works; substring search on an encrypted name cannot work, so we removed it. Encrypted nightly backups, an accurate privacy page and a written incident-response policy made the other answers true. Selecting public distribution cannot be undone, and some of these checks only switch on after that click. **Google Workspace: OAuth verification and branding** A Google Workspace add-on that requests any sensitive OAuth scope must pass OAuth verification, separate from the listing review, before the Marketplace approves it. Our order: scopes identical in three places (the Apps Script manifest, the Marketplace SDK configuration and the OAuth consent screen), then branding verified and published, then data-access verification, and only then the Marketplace submission. Data-access verification asks for a written justification of up to 1000 characters per project and a YouTube link to a video showing each scope in use; the project owner does not see the unverified-app screen, so we film an install from the Marketplace with an outside account. Of our three verification rejections, two were about the privacy policy, not the code: it did not describe data-protection mechanisms, and it did not state with whom Google user data is shared. Naming the third party in passing was not enough; reviewers look for a dedicated section that names the recipient, says what is sent and excludes everyone else. Branding verification is separate and fragile. Renaming the consent screen silently resets it; a verified result is not shown to users until you publish it, and it expires after 7 days if you do not. One logo of 15 was rejected because a generic motif did not identify our brand; a glyph that names the app's job passed first time. An audit also found 5 of 11 live add-ons with no logo on the consent screen, a rejection waiting for the next review. One consent trap reached users: our authorization prompt supplied its own URL with no account hint, so people signed into several Google accounts landed on a page inviting them to request access to the script itself. In two days that produced 12 requests from 7 users across 5 apps. Letting Google supply the account-bound URL fixed it. **Where we were wrong** Our first data-access justifications read well and contained statements the code contradicted: that nothing leaves the user's account, in an app that sends email; that nothing is stored, on a server that caches to disk. An adversarial audit that read the code as a reviewer would caught them. The final texts are now versioned and reused, never rewritten from memory. We first answered No to offering a DPA on the Atlassian tab, because we did not have one. That was honest and it was a sales blocker; writing the DPA turned it into a Yes for 11 AI apps. We treated a saved form as a submitted one: the Atlassian tab said the draft was saved, but no submit action existed while the apps were in review. We now also paste the answers into the review ticket. And we reported the account-hint fix as done when it existed only in local code; the add-ons stayed broken a day longer. **Limits** This method keeps declarations consistent with the code. It is not legal advice, and an app handling health, financial or children's data needs a lawyer. It is not a certification either. Some behaviours we describe are quirks we observed, not documented rules, and the forms change. If an app embeds a generative model, read the provider's terms on minors early: Google's terms for its generative AI services forbid use in a service directed at, or likely to be accessed by, people under 18, with no allow-listing even with parental consent. **Checklist** **Before any form:** a data map per app from the code; the processing chain named; a privacy policy with sections on protection, sharing and AI training; a security page and, if personal data reaches you, a self-executing DPA. **Per platform:** Atlassian tab answered by group, both questionnaires, answers pasted into the ticket; Shopify PCD fields matched to the model and webhooks, every required control real; Google scopes identical in three places, branding verified, published and re-verified after any rename. ### How we make a game in Godot We are building SOLOMONAR, a first-person folk-horror game set in a Maramureș village over seven days, with agents doing most of the typing and one person deciding. The method rests on three decisions taken on day one, a world that can be checked without a screen, and one rule: no automated check is trusted until we have seen it fail on a world we broke on purpose. **What this is for** SOLOMONAR is a first-person folk-horror game made in Godot 4. You arrive in a village in Maramureș, you have seven days, a girl has been found dead at the boundary between two plots, and the village already has a word for what happened. That is as much of the story as our public page tells, and it is as much as we will tell here. This article is about how the game is produced. One person directs, plays and judges; agents write most of the code, the tools and the tests. Our estimate for the whole game is about **2,750 hours**, down from a first estimate of about **6,000** after several rounds of adversarial review. No feature was cut to get there. What changed was the method, not the scope. About **a quarter** of those hours belong to the person directing and cannot be handed to a machine or to anyone else: tuning, reading every line of dialogue aloud, and cultural judgement on assets. Agents do not compress that part, so we plan around it rather than hoping it shrinks. **Three decisions on day one** **1. The simulation runs without rendering.** Keyboard injection under headless Godot has been broken since February 2023 and the issue is still open, so a headless game cannot be driven through input. Our village simulation is therefore a plain data module driven through a named API (`tick(dt)`), with no nodes, no scene tree, no camera and no input. If the simulation is tangled into nodes, nothing can be tested automatically, and the agents stop saving any time on testing. **2. Freeze the engine version.** We build an empty project containing every add-on, open it once on the development machine and once on a server, and only then start. We pin Godot **4.6.3**, because 4.6.2 has a regression (issue 118110) in which using `TIME` in a sky shader breaks the lighting. **3. Collapse the state space instead of repackaging it.** Each of the seven days has one fixed weather. With several possible weathers per day, the combinations of day, weather and social state can only be sampled by a tester; with one weather per day, the whole space can be enumerated in a single overnight run. We had first believed that “one day times N states” was cheaper than N days. It is not: a game with exactly that architecture, in 2D, took seven years. The matrix stays the same however it is packed. Fixing one axis is what shrinks it. **From design document to playable build** Every subject has its own design document: architecture, the attested folklore behind each mechanic, the voice licence, and a list of forbidden assets. Part of the design is executable. At start-up the project checks its own budgets and promises: **10 to 12** scenes in the whole village and **7 to 8** interiors; every clue has two doors, one where you are told and one where you see for yourself; no conclusion depends on a single source; and a solver proves that the investigation can be completed within the seven days on both paths. Content lives in tables, not scenes. A villager is one row and takes **15 to 40 seconds** to add, without touching a scene. A rule refers to a predicate registered by name, never to an expression stored in a string. The line where data-driven design becomes a mistake is the moment you start writing a language: string conditions have no types, no stack trace and no tests. The working order is: shape decisions in writing; download free-licence assets, each with a licence file next to it; a short hardware probe; a visual loop in which the agent presses Play and reads the error; the pure simulation with a headless soak; and a teleporter that loads a named game state directly. The result today is a village you can walk through on our development machine, knock on doors and call at gates, where calling is public and the neighbours hear whom you are looking for. **Engine traps that fail without an error** **`scaled()` moves the origin.** In Godot 4, `Transform3D.scaled()` works in the parent's frame and multiplies the origin as well as the basis; `scaled_local()` touches only the basis. A tree-placement script called `scaled()` after setting the position, and measured trees ended up as high as **56 m** in the air, others half buried. The same call is harmless when the origin is zero, which is why “we used it before and it worked” proved nothing. **Two WorldEnvironments, one used.** The Sky3D add-on extends `WorldEnvironment`. Our scene already had one, so there were two valid nodes, and Godot rendered with ours: flat background colour, no sky. The whole sky, sun, moon and clouds were ignored, and the top third of every frame was a flat rectangle. There was no error and no warning. An agent found it by comparing the resource ID of the active environment with each node. The fix is to make both share the same `Environment` resource. In the same family, a function that handed over to the sky and returned before applying fog and ambient occlusion meant those settings never reached the screen. **No LOD on `.obj`.** Godot generates mesh levels of detail automatically at import, but not for `.obj` files; the documentation says so. Our terrain was an `.obj` and had no level of detail at all. We now export everything as glTF. **Smaller ones.** Height fog in Godot has no distance term, so our settings put between **28%** and **53%** fog on the ground at zero metres. `Expression` with `const_calls_only` does not block utility functions, so a condition can call `randf()` and silently destroy the determinism of a soak run; we whitelist functions at parse time. Godot crashes on software Vulkan (issue 82435), so captures made on a server without a GPU use a different renderer and are different images. **Testing a 3D game with agents** Probes and captures run in a copy of the engine that the operating system never brings to the front, with a dummy audio driver, rendering into an off-screen viewport. They do not take the focus or the mouse from the person working. A world checker measures the scale of every animal against real heights, finds anything inside a wall, floating or buried, and checks gate widths. We tested it by putting the fixed bugs back on purpose: it reported **8** problems, and with the fixes it reported a clean world. That is our fixed rule. No gate ships until it has been seen red on a deliberately broken world. Of **nine** gates as first written, **three** would have gone into production lying. We also check the checker. During a night of shadow optimisation, our probe drew a configuration the game does not draw: **80.8%** of the shadow work we were measuring did not exist in the game. Before any rendering verdict we now prove that the probe and the game count the same thing. Server captures detect regressions; they never approve anything. The final visual verdict is given on the machine with a GPU, by a person. To keep that person from becoming a queue, agents bring a comparison rather than a question: three fog variants on the same frame, ten versions of the same line. **Where we were wrong** We assumed agents would speed the whole project up by **1.8x**. Checked against the work mix, it is about **1.5x**: high on code and text, **1.0x** on artistic judgement, and possibly below 1 on tuning, because more code per hour also means more to tune per hour. We optimised shadows for a night on false numbers and gave a wrong verdict twice. The real cost of a frame was elsewhere: ground **55%**, ambient occlusion **31%**, vegetation **1.9%**, shadows **0.3%**. We blamed tone mapping and saturation for washed-out images before working out the fog formula. **Current status, stated plainly** Built: a walkable village with daily life, voiced villagers and automated checks, running on our development machine. Not built: a release. SOLOMONAR has no players and no reviews, and nothing here is a claim about either. The repair list from the last play-through still had open items, among them a mill wheel that passes through its building, the geometry of the church tower, the trees and most textures. The first planned deliverable is a single complete night, not the whole week. We also say this: a separate study of the market for games of this kind concluded against building a game as a business. SOLOMONAR is a production method being tested, and it should be read that way. **Checklist** **1.** Can the simulation run with no node, camera or input? **2.** Is the engine version pinned and checked against known regressions? **3.** Which axis of the state space have you fixed? **4.** Has every automated gate been seen failing on a broken world? **5.** Do the probe and the game draw the same thing? **6.** Is the final visual verdict given on the player's renderer, by a person? **7.** Are you using `scaled_local()` after setting an origin, one `Environment` resource, and glTF rather than `.obj`? ### Game assets on a small budget: CC0 characters, animation retargeting and local Romanian voices For SOLOMONAR we take people, animations, voices and music from free sources and run the voice generation on our own machines. The method is to read every licence inside the file, under the heading it sits under, and to check the whole chain an asset came from. That reading reversed one of our own decisions: the Romanian voice we had cleared turned out not to be cleared. **What this is for** SOLOMONAR is a first-person folk-horror game set in a Maramureș village, built in Godot with a budget of zero for assets. That makes the licence of each file a condition for the game existing at all, not a detail for the credits page. A label on a repository page is not a licence. We read the licence in the file we download, and we save a small licence record next to every asset, which freezes the evidence and builds the credits section on its own. This article covers four things: human characters, animations, Romanian voices, and music. It describes what the licences say as we read them. It is not legal advice. **Characters: CC0 people that share one skeleton** Our villagers come from two CC0 sources. The Quaternius peasant characters are exported with an Unreal-style skeleton of **65** bones, at a native height of **1.56 m**, which removes a whole class of scale bugs. The same author's “Smooth” characters use a different set of **42** bones, and another export of the animation library uses Rigify names. Between those two there are **0** bones in common, which is why our first attempt could not work. For faces we use MPFB2, the Blender add-on of MakeHuman. Its code is GPL-3.0, but its base mesh, targets, textures, clothes and rigs are CC0, and its licence states that output generated through scripting is not claimed. The generator script therefore lives in our tools folder, marked GPL, shares no code with the game and does not ship. We use only MPFB2, never an export from the standalone MakeHuman application, whose site publishes different licence wording. Its game-engine rig is the Unreal skeleton: **51** of **53** bones have the same name as in the Quaternius library, so the animations land on every track after two renames. MPFB2 also ships **25** facial expression units (blink, brow, mouth corner), from which emotions are composed in the game. Things we ruled out: MB-Lab, whose licence makes generated characters AGPL-3; Hunyuan3D, whose licence excludes the EU, output included; and the traditional Maramureș costume, which as far as we found does not exist anywhere as a wearable game garment. What exists are static museum scans of mannequins, useful as reference and nothing more. **Animations: it was not retargeting after all** The animation libraries we use are CC0: Quaternius's two universal animation libraries, with **43** and **45** clips, and the KayKit character animations, whose licence file says CC0 and free for commercial use. We also use motion-capture data whose terms allow any use as long as the data itself is not resold. Because characters and clips share the skeleton, no retargeting is needed. What fails is binding: on import, **0** of **53** tracks moved, because each track points at the skeleton node of its own source file. The fix rewrites only the node part of each track path and leaves the bone name alone. One small tool now binds **21** animations with none dead. A different rest pose (T-pose against A-pose) does not matter in Godot, since animations write absolute local transforms. What matters is that position tracks exist only on the root and the pelvis. Gaps we plan around: the motion-capture set has no child walk, so children in the village never walk alone across it; they stand, run short scripted paths or play in place. It also has no scything, spinning or sign of the cross. **Romanian voices, generated locally** Villagers speak in Romanian, generated on a laptop CPU with no GPU, as a build step. The game ships plain audio files and never sees the speech model. The village currently has **268** voice files, about **900 seconds** of speech. Most attractive Romanian text-to-speech models are non-commercial: XTTS, the Meta MMS Romanian model, F5-TTS, the SWARA voices and others. Our first choice was the only Romanian Piper voice, out of **176** Piper voices. Its model card says `License: CC0`, and we took that as clearance. It was not. That line sits under the heading **Dataset**: it is the licence of the Romanian recordings. The card declares no licence for the model, and its next section says it was fine-tuned from an English voice whose training data is licensed for research only, explicitly excluding commercial voice synthesis. Asked about exactly this, the upstream maintainer replied that he could not offer legal advice. We do not claim a legal verdict either way, but nobody in that chain has granted a commercial right, and a product sold on that basis would be a bet. We stopped betting, because replacing it costs one run. The replacement is a Romanian VITS model from the Coqui registry, licensed BSD-3-Clause in two separate files, trained from scratch on Mozilla Common Voice, which is CC0. It has no parent model. It is a single female voice, measured at a median of **205 Hz**, against about **108 Hz** for the old male one, so every voice target had to be rewritten in hertz. From that one voice we make the others with the WORLD vocoder, which changes pitch and spectral envelope separately. Pitch alone turns a man into a sped-up man; a woman needs both numbers. One rule holds regardless of licence: CC0 gives up copyright, not personality rights. The speaker is a real volunteer, so no character runs on the unaltered voice, and we never convert a voice towards a real person without signed consent. One thing is still open: the new voice has been measured but not yet judged by ear, and that verdict belongs to a person. **Music and props** For folk music the plan relies on Béla Bartók's 1923 collection from Maramureș: **378** melodies from the game's own region, in the public domain worldwide. We dropped early 78 rpm recordings: they are public domain in the EU but protected in the United States until 2060 or later. We also have a tool that generates music with Google's Lyria model. Its terms permit commercial use, but it comes with no IP indemnity, purely generated output is not protected by copyright in Romania or the United States, so re-uploads cannot be stopped, and YouTube requires AI-generated music to be disclosed. Romanian is not an officially supported language. For props, the Sketchfab account of a Romanian cultural-heritage institute publishes **731** downloadable models, **728** of them CC-BY. The other three are non-commercial, so the licence is read per model, not per account. The default listing order silently missed **107** of the **731**; sorting by publication date found them. The platform changed owner in August 2026, which is why we download now and keep the licence record beside each file. **Where we were wrong** The Piper voice is the main one: our own voice script stated for days that the model was CC0 and commercially fine, because we read a licence field without reading the heading above it. We also described a Sketchfab account as belonging to a Romanian open-air museum; a count showed only one of its **277** models carried that name and **102** were Greek. And our animation check first marked two clips as dead because it sampled one hand at two moments, in clips where the hand rightly stays still. It now takes eight moments across six bones and uses the largest deviation. **Limits and checklist** None of this has been tested by a store review or by a release; SOLOMONAR is unreleased. Licences and platforms change, and CC-BY assets require visible credits from the first day. **1.** Read the licence in the file, under its heading. **2.** Follow the chain: base model, fine-tune, dataset. **3.** Keep generators with copyleft code outside the game. **4.** Save a licence record next to each asset. **5.** Bind animation tracks to your skeleton before concluding you need retargeting. **6.** Never ship an unaltered real voice as a character. **7.** Have a person listen before a voice is approved. ### Publishing without invented numbers: how we write these articles The articles on this site are drafted by AI agents, checked by other agents and then by a script with no model in it. The method rests on one finding from the first round: language models invent quantities in words as readily as in digits, so a checker that only reads digits passes most of what it should stop. **What this is for** Our research articles make claims a reader can check. A wrong figure costs more than the article earns. We draft with AI agents because that lets a small team publish in English and Romanian, and the price is a writer that will supply a plausible quantity whenever a sentence seems to want one. So the method does not rely on the writer behaving. It assumes the writer will invent, and it is built to catch the invention before publication. The same approach applies to anything generated at scale from a set of facts: articles, product copy, social captions, question banks. The examples below come from several of these. **The brief is the only source** Every piece starts with a brief that holds the exact facts and the file each one came from. Writers get the brief and the source files, nothing recalled from memory. The brief also lists what gets a draft rejected: a number without a source, a person's name, an internal identifier, any sum of money, and anything that blurs *published* with *built*. Every number in a draft, and every quantity written as a word, must appear in a facts list attached to the article, paired with its source file. The list is part of the deliverable, not a note for later. The brief is also where errors start. On another project, a list of "confirmed facts" we handed to generating agents contained mistakes of ours, and the agents repeated them with the same authority as the true ones. It happened four times. Once it produced a quiz question with no correct answer, because the explanation repeated the same inverted figures and nothing inside the text could contradict them. Three independent verifiers caught it only because they read the primary text in full rather than our list. Since then, facts live in a versioned file with the source and the date we read it. Anything unconfirmed sits in a separate section that writers are told not to use. Verifiers are asked to challenge the list, not only the drafts. **Writers, verifiers, repair** **Writers.** For the research section, six agents each wrote one article, in both languages, from the brief. **Verifiers.** Six more agents each took one draft and checked every figure against the brief. They run with a separate prompt. A writer checking its own draft checks what it meant to say, not what it said. **Repair.** Repair agents applied the verifiers' findings. The first round contained 25 invented quantities, and 117 corrections were applied in total. Repair is not the last step. A rewritten paragraph is new text that nobody has checked, so the repaired draft goes back through the gate described next. **The deterministic gate, and what it missed** The final gate is a short script. It pulls every run of digits out of both language versions and compares each one with the set allowed by the facts list. A number outside the set fails the article. There is no judgement in it, which is the point: it cannot be persuaded. We expected it to be the strong check. It was not enough on its own. Run on the unrepaired drafts, it passed them too, because the invented quantities in those drafts were written in words: "a few minutes", "three incidents". A digit extractor does not see words. The agent verifiers caught the quantities in words; the script caught digits the agents read past. Each catches a class of error the other misses, and neither is enough alone. That is why the brief now requires quantities written in words to be in the facts list as well, and why both checks run. **Checks belong on the text that ships** A different pipeline taught us where to put a check. It drafts customer replies through a long chain of steps, several of which regenerate the text with a model. A deterministic corrector for one recurring phrasing ran once, at the start of the chain. At least five later steps rewrote the text. The same error came back three times in a row, each time by a different path, and each time we believed it fixed. The diagnosis came when we ran the corrector by hand on the final output, and it fixed it. The function was fine; the draft had simply never passed through it. Correctors now run at the common exit, on the text that actually leaves. Ones that are safe to repeat run at both ends. After any step that regenerates text, we ask which checks the new text has skipped. A style gate runs last. It removes invisible Unicode characters that generated or pasted text can carry, flags a list of phrases that read as machine-written, reports overused dashes and gives a score out of 100, with 80 as the bar for publishing. Flagged phrases are listed, not rewritten automatically. Rewriting them with a model would produce more of the same. **Where we were wrong** We trusted a prompt to prevent a defect. A pipeline that writes social captions from video clips asked the model for the brand's voice. When a clip showed a person introducing themselves by name, the model wrote that introduction as the brand's own words and turned the name into a common noun. It was published. We did not generalise from one case. A scan of the queue found one such caption in roughly ninety, so the fix is narrow: a deterministic check for a self-introduction with a name, then a rewrite, then rejection if the rewrite still fails. Our first version of that check matched without regard to case, which defeated its own capital-letter pattern, and its first scan reported 23 broken captions that were fine. We now test a guard on good and bad examples before believing what it reports. The same pipeline also misspelled proper names, systematically rather than by accident. The correction is deterministic: a list of names taken from product titles, with fuzzy matching only on capitalised words, at a similarity of 0.82. Each guard around it was paid for with a real false positive found in a dry run. **Markdown twins and llms.txt** Every article also exists as a Markdown file with front matter, a citation and a CC BY 4.0 licence. The HTML page links to it as an alternate, and llms.txt lists it. Language models and crawlers read plain text more reliably than a styled page, and an article we want cited should be easy to quote exactly. One trap. The web server did not know the Markdown type and served the file as generic binary data, so browsers downloaded it instead of showing it. It took one rule in the server configuration. The check that proves the fix is the content-type header in the response, not a 200 status. **Limits** The method shows that every number traces to a source file. It does not show that the source is right; that is the job of the facts list, and the facts list can be wrong. It does not catch a true figure used in the wrong context, or a vague claim that contains no quantity at all. The two gates cover the classes of error we know about. The quantity-in-words class was found by failure, not by design, and we expect there are others we have not met yet. **Checklist** Facts go in a file, with the source and the date read, before anything is written. Writers get the file. Verifiers are separate agents and challenge the list as well as the draft. Repair is followed by another check, never by publication. A script compares every digit with the allowed set; agents look for quantities in words. Correctors run on the final text. The style gate runs last, and flagged phrases are rewritten by hand. The served content type of each Markdown twin is checked, not assumed. ### Working with a fleet of AI coding agents without losing work We run many AI coding agents at once, often on the same files and the same systems. Almost nothing we lost came from bad code. It came from coordination: agents sharing a scratch space, two runs writing one live file, a deploy that published someone else's half-finished work, and runs that died together and resumed blind. These are the rules we follow now. **What this is for** Parallel agents multiply output, and they multiply the ways to lose it. Every incident below happened to us, with our own agents, on our own systems. In none of them did a model write bad code. Each was a place where two pieces of work met and neither knew the other was there. The rules are ordered roughly by where the damage starts: shared space, live files, deploys, runs that stop, and the checks that decide a fix is finished. **Shared space is not private space** We started 14 agents in parallel, each on its own file, with the instruction to back up the source before changing it and restore it afterwards. They all chose the same obvious backup name, in the session's scratch directory. That directory looks private but is shared by every agent in the session. The backups overwrote one another, and the restores wrote one project's code into another. 9 of 16 files were affected. A diff said the restored files were identical to the originals. Both sides of the comparison were already contaminated. What caught it was a hash taken before the run and compared after. Production was untouched only because the deploy had happened before the fan-out. **Rules.** Any fan-out prompt demands unique backup names, or puts backups next to the file they protect. Measure the starting state instead of comparing two states that may both be wrong. Put the tree under version control, so the next incident is a checkout; that project went under git the same day. **One owner per live file** Two of our runs, started in parallel on related jobs, could both write the same live script. The first replaced it during its repair stage. The second had already built on the old version and was verifying when its verifier found the file changed underneath it. It reported, as critical, an uncoordinated actor. The actor was our other run. Nothing harmful happened only because neither had applied anything yet. Each prompt described the system as it was at launch, and neither knew the other was allowed to write. Parallel runs collide at the first repair, not the first read. **Rules.** Before starting parallel runs, list the live files each one can write. Give every file one owner and tell the other runs it is read-only. Where a step depends on live state, check its checksum immediately before the step, not the one recorded at launch. When a run reports an unknown actor, look at your own other runs first. Inside a shared folder the same idea works at a smaller scale. Make targeted edits rather than whole-file rewrites, and re-read just before editing, so that a stale match fails safely instead of overwriting. Complete another session's intent rather than replacing it. On an area that belongs to someone else, a small, clear correctness fix can be applied and noted; a judgement call is reported with the exact fix, not made. **A deploy publishes the disk, not your intent** One deploy script synced the whole tree to production. We deployed six times without touching another agent's files and still published its work in progress, because a deploy does not ask who wrote a file. The build passed and the health check answered. Nothing signalled it. On another day we published two of our apps with code we had not intended: one carried another app's file, the other showed a debug probe in its panel. We had deployed from a tree that a parallel run was modifying. The push reported success, correctly, because the transfer worked. A successful push says the transfer worked, not what was transferred. We then made it worse. We said the probe had not reached users, on the strength of a search through the git history. It had never been committed, so the search could not have found it. Absence from git is not evidence of absence from production. **Rules.** Before deploying from a shared tree, look for recently modified files you did not write, and tell their owner before, not after. Do not deploy while a run is touching source. After deploying, compare the served source with the local source, file by file. Our deploy script now does this, stops before switching versions if they differ, and refuses to start with uncommitted changes. Its first run found a second contamination we had not been looking for, which is how we knew the check was worth having. **Runs die together; resume from evidence** An account's session limit stops every agent at the same moment. Once, nine runs died together. Work was left uncommitted in worktrees, and one step that wrote to production was half done. A resumed agent starts from zero with the same prompt, unaware that half its steps have already happened. Interrupting the main session stops every background run too, with no failure notice. Eleven died at once that way. And not every interruption is what it looks like: one run's agents showed interruptions we blamed on new messages, but the run's own log showed a stall watchdog restarting them after about twenty-eight minutes without progress. Sending a message to an agent inside a run did not reach the running agent. It started a second copy from its transcript, and the two worked in the same worktree, each convinced that another agent was overwriting its files. This happened twice in one day. **Rules.** Every step that writes to production keeps a line-by-line log. Before resuming, read the real state: worktree status, the action log, the dead agent's working files. Put it in a resume note that says what is done, what was started and what was never logged, and tell the agent to review rather than redo. Relaunch in waves of at most three, in priority order. Do not message agents inside a run; apply new decisions after it ends. **A fix is not finished until something else checks it** Twice in one day a code run ended with its repair round reporting everything green and no verifier after it. In one of them, an independent verifier then found the repair was partial: a "cancel all" command opened the dialog for the opposite action, across the whole list. A fixer tests its own understanding of the problem. The decision to ship comes from an independent verifier on the final commit, rerunning its own attacks and the previous verifier's. A single verifier can also cancel an angle by construction. We ran five independent angles, each followed by an adversarial verifier, but wrote that verifier for the main question. The fifth angle asked something different, so its findings had a different shape, and the verifier rejected every one: 3 raw findings, 0 confirmed. One of them was real and more urgent than the question we started with. Verifiers are now written per angle, and reports show raw findings next to confirmed ones, so a zero made by the harness cannot pass for a zero made by reality. **Where we were wrong** Three times in one day we repaired a rule that was not running. One was called by nothing. One was switched off by configuration everywhere it could apply. One had a guard that compared ranks with greater-or-equal and so excluded exactly the case the rule existed for: it resolved 0 of 2,258 cases, and 47 once the comparison was corrected. A fourth time, a test had disabled a guard so it could exercise the main path, and the guard itself was wrong in production. Code that does not run looks identical to code that runs and finds nothing. Before changing a rule we now ask it two numbers: how many times it ran, and on how many cases it produced a result, measured where it is actually called. We also tested a fix only where it was needed. A new rule looked flawless on 160 failing cases. Run on 400 cases known to work, it would have rewritten 91 of them. The failing set holds no counterexamples, because anything looks like an improvement on something that does not work. A fix is tested on what already works, and the target there is zero changes. **Limits** These rules reduce collisions; they do not detect all of them. They depend on knowing which files a run can write, and agents do not always say. Hashes and served-source comparisons prove a state at one moment, not that nothing changed afterwards. Waves and independent verifiers cost time, and we pay it knowingly. **Checklist** Unique names in any shared space. One owner per live file, and a checksum immediately before any step that depends on it. No deploy while a run edits source; compare served with local afterwards. Production steps log line by line. Resume from state you have read, with a resume note, in small waves. No messages into a running workflow. An independent verifier is the last step, written per angle, reporting raw counts. Ask a rule how often it ran before you fix it, and test the fix on what already works. ### The honesty gate: no feature claim without proof in the code Before any submission we compare every claim on a plan page, a listing or a public page with the code that is supposed to back it. A claim stays only if a merchant can reach a working implementation; otherwise we build the feature or delete the claim. A script catches the mechanical cases, and the rest is read by hand. **How we found out** On 13 July 2026 one of us clicked the button to start a 7-day trial in FAQ Genie, and nothing happened. Following that thread through our Shopify apps showed that the upgrade button was dead in 14 of 17 apps, that three apps would have created test subscriptions in production, and that we were selling features that did not exist. Four apps had no real product. One showed ad metrics produced by a random number generator, one sold videos and had no encoder, one audited events from a pseudo-random generator, and one sold scheduled price refreshes with no scheduler at all. Four more had a paid tier that added nothing. TermForge, in review at the time, published a data-protection page saying it could not read orders while its configuration requested order access. Several repositories contained a copy file marked *do not claim* for exactly the features the plan page then sold. None of it was deliberate. The copy was written before the product, and nobody compared the two at the end. The honesty gate is that comparison. **The rule** No bullet without proof in the code, and no page without proof in the code. A plan bullet, a listing claim or a sentence on a privacy, terms, FAQ or data-protection page may exist only if a reachable implementation backs it. Reachable means a merchant can get to it through the interface, or an automatic worker, webhook or scheduled job really runs it. Code that exists and is never called is not a feature. When a claim is not true there are two options: build it or delete it. In the first remediation the agents were told to build when the work was small, and they produced real schedulers, CSV exports, a web pixel and a video renderer. Where a feature needed an external approval rather than code, we removed the paid tiers altogether, stripped the promises from every public page, and wrote a demo-data warning inside the exported file itself, because a file outlives a banner. **The mechanical half: a script** A script runs lint checks over each app's code and exits non-zero on any blocker. The first version had 12. They look for an upgrade path that swallows the success signal or never renders its error; test billing as the default; a paid priority queue that runs in the wrong direction (in our queue library 0 is both the default and the highest priority, and three apps gave paying customers 1); *automatic* or *scheduled* sold without a registered repeating job; a model call with no meter in front of it; a plan flag that nothing reads; the chassis app's name leaking into a fork's public pages; a data-protection page that contradicts the declared scopes; an entitlement checked in the interface but not in the action or the worker; and a record fetched by id without scoping it to the shop. Every rejection since then has become a check, confirmed to fire on the code as it was before the fix: form inputs with a name but no value or change handler, which a reviewer cannot type into; a login route with no component, which rendered a bare `null`; the billing redirect that shows a 401 inside the iframe; a web pixel that called `register` without importing it and so never ran. That pixel had collected zero events for every shop that ever installed it, including four review stores. A gate that reports blockers on correct code gets ignored, which is how real bugs hid in the first place. The first version produced four false blockers; each fix is now a comment in the script explaining why the check is narrow. **The manual half: a claim table** No script can tell whether a sentence is true. For every claim we write one row: the claim, a verdict and the evidence as a file and line. The verdict is **works** (implemented, reachable, and it does what the words say), **partial** (real but narrower than the words) or **missing**. We judge the words, not the intent. Some words lie more often than others: unlimited, every, whole catalogue, automatic, scheduled, priority, alerts, multi-store, export. Search counts are not evidence. A symbol can exist as a type, a stub or dead code. One stub returned the literal string `draft-order-deposits-coming-soon` while the plan page sold the feature. We open the file and follow the call chain to an interface entry point or a worker. A number in the copy must be the number in the code: one app sold bulk changes across every audited page and capped them at 500, and sold full history while keeping 365 audits. A database column that nothing reads is not a feature, and a guard that hangs off a field nothing ever writes will never run. The hardest class is a function that is called, wired all the way to the interface, and returns success for work it never did. One of our apps shipped an integration module whose own header described every function as a stub; the app showed a green synced badge and metered a billable unit for the feature it was named after. The script passed it with zero blockers. For any claim that moves money or touches an external system, we now read the body of the function that fulfils it, and we search every claimed path for *stub*, *TODO*, *scaffold* and *coming soon*. **Prove the effect, not the code** Code can be correct while the effect is absent. TermForge's balance page read rows that its order webhook was supposed to write. At the source, 72 webhooks had arrived and every one had been ignored, so the core feature had never fired once. We now ask of every dashboard what writes the rows it reads, and count webhooks in against rows out. Tests can enshrine a bug. A charge failed on every attempt because an idempotency key was 40 characters against a platform limit of 32, and the unit test asserted the 40-character shape. Mocks can be kinder than the platform. In one of our Google Workspace add-ons, a mock answered a call that the declared scope refuses, and the suite stayed green while Outline did not work for anyone, on any document: 13 installs and zero opens in 10 days. Our mocks now keep a list of allowed methods per object and throw the platform's own error for anything else, and we check them by mutation: put the bug back and the suite must turn red. So for anything that touches money or a platform, the last step is the real loop on a live store: place the order, watch the row appear, press the money button, see it marked paid. **Where we were wrong** We thought one careful audit was enough. A second pass the next day, with 11 apps already in review, found six more real bugs. Among them: an app whose entire product could never be created, because its extension and scopes had not reached the platform; a headline paid feature that anyone could keep using after cancelling a trial; one merchant able to read another's records; model spend with no upper bound, because failed calls were free and the merchant could trigger the failure; a page describing a safety check that never ran. The first pass had caught none of them. The worst bug of that pass was ours, written while fixing the others. A billing webhook called a function with a variable that did not exist in its scope. The build was green because five apps did not type-check their routes, the code deployed, and no shop could have started paying. A subagent porting the same change to another app caught it by reading the shipped bundle. Since then, a fix to a money path gets a second reviewer, and routes must be type-checked. The gate itself was also wrong after that. It accepted a null check as a tier gate, and it flagged working code that kept its cancel logic in another file. We fixed both, because a muted gate is how the real bugs hid the first time. **Limits** The gate shows that known classes are absent and that each claim has a reachable implementation. It does not show that the implementation is correct, that the platform accepts it, or that anyone wants the feature. It is a lint, not an oracle. The claim table and the live loop stay manual, and so far each new rejection has added a class we did not have before. **Checklist** Run the script on the app and fix every blocker. Write the claim table for every plan bullet, listing line and public page, with file and line. Build or delete every partial and missing row. Find the constant behind every number you print. Read the body of every function behind a claim about money or an external system. Count ingest webhooks against stored rows. Click the upgrade in a real browser through to Shopify's approval page. Have someone else check your fix to any money path. Run the gate again after any change to a plan page, a public page, billing or a model call. ### How to make a website that AI assistants can read and cite Publish every page twice, as HTML for people and as Markdown for machines, and generate both from one source so they cannot disagree. Add an llms.txt index, an Atom feed, Article structured data that points to the Markdown, and a robots.txt that names the AI crawlers. Then check each file by what the server actually sends, not by whether it answers. **What makes a website readable and citable by AI assistants?** A website is readable by AI assistants when its text can be fetched without running JavaScript, without guessing what is layout and what is content, and without guessing which number belongs to which claim. It is citable when each page has a stable address, a title, a date, an author and a licence that an assistant can repeat. The method below is the one behind goingstellar.ro: a plain-text twin for every article, indexes that list everything in one request, and a build that writes all of it from the same data. By *AI assistants* we mean ChatGPT, Claude, Perplexity, Gemini and the AI answers in search engines. They reach a page either through their own crawlers or through a search index. Neither route rewards a page that hides its text behind scripts or scatters its facts. **Generate every output from one source** **1. One source of truth.** All text and all figures live in one content file, in both languages. A build script writes the HTML pages, the Markdown twins, the indexes, the feed, the sitemap and robots.txt from that file. Nothing is edited by hand after the build. We started this way because the previous version of the site was two hand-written HTML files of about 700 lines each, one per language, and they had already drifted apart: the number of apps listed for one marketplace was different in English and in Romanian. A mirror that can fall behind the page is worse than no mirror, because an assistant will quote the stale one with confidence. A generated mirror cannot fall behind. The build also stops itself. Counts that appear in headings are tied by assertions to the length of the lists they describe, so a mismatch breaks the build instead of reaching a visitor. **Give every page a Markdown twin** **2. A Markdown twin per article.** Every article is also written as a `.md` file at the same address with `.md` appended. The Markdown twin carries front matter (title, description, date, language, canonical address, licence, the address of the other language), the full text with its headings, a ready-made citation, a BibTeX entry and a CC BY 4.0 licence line. The citation block is there so that nobody, human or model, has to invent the format. **3. Announce the twin.** The HTML page links to its Markdown twin with `<link rel="alternate" type="text/markdown">` and shows a visible link to it. The conversion from HTML to Markdown is complete rather than approximate, because the source paragraphs may only contain bold, italic and code markup. The reason for the twin is practical. An assistant that receives HTML has to strip the layout first, and the stripping loses exactly what matters: which line is a heading, which figure belongs to which claim, where the limitations are. The Markdown twin is the article without the layout. **Publish llms.txt and llms-full.txt, without overselling them** **4. Two plain-text indexes at the root.** `llms.txt` is a short Markdown index: who we are, what we publish, every page with a one-line description, every article with the address of its Markdown twin. `llms-full.txt` holds everything: each application with its description, each published figure with the system it came from, and the full text of the methods and research. Both are regenerated on every build from the same data as the pages. We do not claim llms.txt makes a site more likely to be cited. The search guidance we work from records that Google's own AI optimisation guide lists llms.txt among the things a site does not need, and that no major AI search provider has confirmed reading third-party llms.txt files. Coding assistants do read such files when loading documentation. For us the file costs nothing to generate and gives any reader a complete map in one request; that is the whole claim. **Feed, structured data and robots.txt** **5. An Atom feed.** Each language has an Atom feed for the research articles and another for the methods, newest first. Every entry links both the HTML page and its Markdown twin, so a reader that follows the feed never has to parse the page. **6. Article structured data.** Each article carries JSON-LD of type `Article` with headline, description, language, publication date, author, publisher, licence, word count and canonical address. It also carries an `encoding` object of type `MediaObject`, with `encodingFormat` set to `text/markdown` and `contentUrl` pointing to the twin. That tells any parser of structured data that a clean text version exists and where. **7. A robots.txt that names the AI crawlers.** The general rule already allows every crawler. We still name 22 AI and search crawlers one by one, each with `Allow: /`, and point to the sitemap, which lists every page with its other-language pair. Naming them makes the permission a statement rather than an omission. **Serve the files correctly, and check what the server sends** **8. Teach the web server what Markdown is.** Our nginx did not know the `.md` extension, so it served the twins as `application/octet-stream` and browsers downloaded them instead of showing them. One location block for `.md` fixes it: `text/markdown` as the type, UTF-8, and open cross-origin access. **9. Verify by content type, not by status.** A `200 OK` proved nothing here: the broken files also answered 200. The check that counts reads the response header, for example `curl -sI` on a Markdown address and a look at `content-type`. We now check the Markdown twin, llms.txt, the feed and robots.txt by header after every deploy. **Write pages that can be quoted one paragraph at a time** **10. Structure.** Each article has one `h1`, one `h2` per section, the date in a `<time>` element, and its limitations in a section of their own rather than in a footnote. The first paragraph answers the title directly. Each section names its subject again instead of saying *it*, so that a paragraph quoted alone still makes sense. Newer articles end with short takeaways and a few questions answered in full. **11. A numeric gate before publishing.** A text that is meant to be quoted must not contain a figure we cannot source. Every number in an article has to appear in a facts list next to the file it came from, and a script with no model in it rejects any digit outside that list. In the first round of articles, checking found 25 invented quantities, and we applied 117 corrections. Agents also look for quantities written in words, which the digit check cannot see. The last gate reads the generated files, not the source. An earlier wrong claim about the number of countries we ship to (eight instead of seven) had been fixed on the page and survived in a meta description. A check that looked only at the text just edited would have missed it. **Where we were wrong, and what the method does not prove** We assumed the nginx change also declared the character set. The header check showed the Markdown files served as `text/markdown` with no charset parameter, and llms.txt as `text/plain` with none either. The registration for `text/markdown` makes the charset parameter required, and a client that assumes another encoding will garble Romanian diacritics. The fix was to add the Markdown type, and the plain-text and feed types, to the server's list of types that receive a charset; we applied it the same day and checked the header again. We had verified the type and not the whole header. The methods section has its own feed and its own Markdown index, built from the same data as the pages. The main limit is measurement. We have not yet measured how many visits, if any, reach the site from AI assistants, and we have no evidence that any assistant has cited these pages. The method makes the site easy to read and easy to quote correctly. It does not make anyone ask about it, and it does not replace being indexed by search engines, which is how most AI answers find a page in the first place. **Checklist** One content file; everything else generated. A Markdown twin per article, linked as an alternate and in the article's JSON-LD `encoding`. llms.txt and llms-full.txt from the same data, described honestly. An Atom feed with Markdown links. robots.txt naming the AI crawlers, and a sitemap with language pairs. The server sends `text/markdown` with a charset, checked by header. One `h1`, sections that stand alone, limitations in their own section. Every number in a facts list, checked by a script on the generated output. ### How to make a Shopify store readable by AI assistants (GEO for e-commerce) Generative engine optimisation (GEO) for a Shopify store is ordinary SEO aimed at AI answers: let the AI crawlers in, write pages that answer questions in quotable passages, add structured data that is true, give Shopify's own assistant real FAQs, and measure visits that arrive from AI referrers. This is the method we built into our Shopify app Cite Me, with the mistakes that shaped it. **How do you make a Shopify store readable by AI assistants?** To make a Shopify store readable by AI assistants, first make sure robots.txt does not block the AI crawlers, then rewrite product and collection pages so that each one answers a buyer's question near the top in a passage that makes sense on its own, add honest structured data (Product, FAQPage, Article, BreadcrumbList, Organization with `sameAs`), fill the Store FAQs that Shopify's own assistant reads, and track visits from AI referrers separately. *GEO* (generative engine optimisation), also called *AEO* (answer engine optimisation), means preparing pages to be quoted in AI answers: ChatGPT, Perplexity, Gemini, Claude and the AI answers in Google Search. The search guidance we work from records Google's position that this is still SEO, and that a page must be indexed and eligible for a snippet before it can appear in any Google AI feature. Everything below sits on top of that floor, not instead of it. We publish a Shopify app, Cite Me, that runs these steps for a merchant. The method is useful without the app; we describe both. **Step 1: check that the AI crawlers can read the store** **1. Read robots.txt per crawler.** Parse the store's robots.txt by user-agent group and check each AI crawler on its own, because a rule for one bot does not apply to another. Cite Me checks a list of 16 AI crawlers, including GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, PerplexityBot, Google-Extended, Applebot-Extended and CCBot, and records for each one whether it is blocked and by which directive. Shopify controls parts of a store's robots.txt, so check what the store's theme and plan let you edit before promising a fix. Crawler access is the one signal that is observed rather than estimated. It is also only a precondition: a crawler that is not blocked can read the store, which is different from citing it. **2. Make sure the answer is in the HTML.** The guidance we use notes that AI crawlers generally do not execute JavaScript. Text that only appears after a script runs, such as a product description loaded by an app widget, may not exist for them. Check the page source, not the rendered page. **Step 2: write pages that answer questions** **3. Put the answer first.** A product or collection page should state what the product is, who it is for and the deciding fact (size, material, compatibility, delivery) in its first sentences. Assistants quote passages, and a passage that needs the previous paragraph to make sense is less useful to them. **4. Use questions as headings.** Headings phrased the way buyers ask (*How do I choose a size?*, *Is it safe for children?*) match the way people prompt an assistant, in Romanian as in English. **5. Use real numbers.** Specifications, dimensions and delivery times with a source are what makes a passage worth quoting. Invented figures are worse than none. **6. Write the meta title and description.** They are the page's own summary of itself, read by search engines and assistants alike. Cite Me scores pages on 9 signals: the 8 from our earlier offline scorer (citable passages, front-loaded answer, question headings, evidence density, freshness, structured data, entity `sameAs`, AI crawler access) plus meta quality. Four of them are scored per page. Crawler access, structured data, freshness and `sameAs` are the same on every page of a store, so they form a separate store score: fixed once, they lift every page. **Step 3: add structured data that is true** **7. Product, Article, FAQPage, BreadcrumbList.** Cite Me adds these as JSON-LD through a theme app embed, which the merchant switches on once for the whole theme. The same embed stamps a date, because freshness is easier to judge when the page states when it changed. **8. No review stars without reviews.** AggregateRating is added only when the product has real ratings in the standard review metafields, and Cite Me warns instead of adding it when a reviews app already emits its own rating schema, since two AggregateRating blocks on one page contradict each other. Structured data must describe what is on the page. **9. Say who the store is.** An Organization block with `sameAs` links to the brand's profiles (and, where it exists, its Wikidata item) helps assistants connect the store to a known entity. Cite Me publishes a brand page that carries it. **Step 4: give Shopify's own assistant real answers** **10. Fill the Store FAQs.** Shopify's Knowledge Base app holds Store FAQs that Shopify's AI assistant uses to answer shoppers; they do not appear on the storefront. When we checked in June 2026 the app had no write API, so FAQs can only be added through its interface. For our own stores we wrote 19 to 20 FAQs each, drawn from the real shop policies, delivery settings, payment methods and the categories of real support tickets, then had a second agent verify each answer against those facts before anything was entered. **11. Review the defaults.** The app seeds default FAQs of its own. Some of ours held placeholder content, such as a sample contact address. An assistant will repeat a placeholder as confidently as a fact. Shop policies, which the assistant also reads, can be edited through the Admin API. **Step 5: measure what arrives from AI** **12. Classify visits by referrer.** Cite Me registers a Shopify web pixel that records each page view's channel by referrer, with AI as its own channel (chatgpt.com, perplexity.ai, gemini.google.com, copilot.microsoft.com, claude.ai, among others), and attributes completed checkouts to the landing channel. It does this without asking for access to orders or customers: the pixel stores only path, channel, referrer host and the checkout value. Search Console and Analytics can be connected for query-level data, but are optional. Readiness scores are predictions; referrer data is the only part of this method that measures an outcome. **Traps we hit, and how we catch them now** **An enabled embed read as switched off.** Cite Me checks the live theme's settings to see whether its schema embed is on. It matched the full identifier from its own configuration, but the theme stores only the standard UUID part of it, so an enabled embed was reported as off and the merchant was told to fix what was already fixed. It now matches the UUID prefix and the block handle. **A scanner that cannot see a protected store.** On a password-protected development store, the scanner cannot read the storefront, so completed store-wide fixes kept scoring low. Cite Me now reconciles: when the theme settings or its own records confirm an action is done, the matching store-wide signal shows as done, with a note that the live scan may lag. Per-page content signals are never overridden this way. **Two screens, two scores.** The home screen and the tracking screen computed the store score differently and showed 43 and 14 for the same store. Both now call the same function. A score that changes with the screen it is read on is not a measurement of anything. **Limits: what this does not prove** The page score is a heuristic, and it is designed so that the app's own fixes can bring a page to 100. That makes it a checklist of things done, not a forecast of citations. A page at 100 can still go unquoted. llms.txt is not a citation lever. The guidance we follow lists it among things Google says a site does not need. Cite Me reports whether a store has one as housekeeping and never scores it as a ranking factor. Correlation figures for GEO that circulate online are mostly vendor studies; our own scorer's documentation tells us not to quote them, and we do not. Cite Me has been live on the Shopify App Store since July 2026. We do not yet have a merchant result we can show, so this article describes a method, not a proven effect on sales. **Checklist** robots.txt checked per AI crawler, and the answers present in the HTML without scripts. Each important page answers its question in the first sentences, with question headings and real figures. Meta title and description written. Product, FAQPage, Article, BreadcrumbList and Organization `sameAs` added, review stars only with real reviews and never twice. Store FAQs written from real policies and checked, default FAQs reviewed. AI referrers tracked as their own channel. Scores treated as a checklist; referrer data treated as the result. ### How we turn repeated work into AI agent skills An agent skill is a folder with a SKILL.md file that tells an AI coding agent when and how to do one recurring job, plus any script the job needs. We extend an existing skill before creating a new one, make every write a dry run by default, and keep the rules that must hold outside the prompt: in hooks, read-only database roles and a shared knowledge base. **What an agent skill is** An AI agent skill is a folder containing a SKILL.md file: a short frontmatter block with a `name` and a `description`, followed by instructions that an AI coding agent such as Claude Code follows for one recurring job. If the job needs deterministic work, such as a database query or an API call, the folder also holds the script that does it. The agent reads the description to decide when the skill applies, so the description does the routing. We keep a shared team catalogue of skills, so a skill written by one person is available to every agent on every machine. The catalogue grew to about 230 skills before a consolidation pass brought it down to about 216. That number is not a target. A skill catalogue is useful in proportion to how reliably the right skill is found, and every near-duplicate makes that harder. Our trigger for writing a skill is repetition. A pattern seen in three or more cases, or a fix we would otherwise redo by hand tomorrow, becomes a skill or a rule in code. A one-off case stays on a list for a person, with the decision that person has to make. **Step 1: extend before you create** The first step in making an agent skill is to check whether one already exists. We match the proposed name and description against the catalogue and look at the closest skills. When the data and the audience overlap, we add a mode or a subcommand to the existing skill instead of creating a new one. Four separate article generators, one per site, became one generator with a site parameter. Several advertising sub-skills became one suite. The same rule applies inside the code. An audit of the catalogue found one small connection-string helper copied into about 40 files, each copy free to drift on its own. Skills now import one shared helper for database connections and secrets, and read precomputed tables instead of recomputing heavy aggregates on every run. **Step 2: write SKILL.md for the router** The SKILL.md frontmatter needs two fields, a `name` and a `description`, with an optional argument hint. The description says what the skill does, then when to use it, then the phrases a user would actually type. The body follows a fixed shape: when to use, the exact steps, and notes with edge cases, data sources and related skills. We prefer a knowledge skill, meaning instructions only, unless the job must run code deterministically. Instructions are cheap to change. A script is a second thing to test, deploy and keep in step with the text. Where the catalogue lives matters too. We used to list every skill, with its description, in the instructions that load on every turn. That file had reached 30.8 KB. Routing actually happens through a semantic search over the full descriptions, which stay in the database, so the always-loaded list was shortened to descriptions of at most 10 words: 17.4 KB, 44% smaller, for every agent on every turn. **Step 3: scripts that run anywhere and write nothing by default** A skill script declares its dependencies inline, in the format defined by PEP 723, and runs with `uv run`. No virtual environment is committed, so the same script runs on any machine that has uv. Secrets are read from the environment first and from a shared secret store second, and are never printed. Scripts that print to Windows consoles force UTF-8 output, because Romanian diacritics otherwise crash them. Every write is a dry run by default. The script prints what it would change, with the matching SELECT and the row counts, and changes nothing until it is called again with `--apply`. When a skill is exposed as a runnable action, it comes as a pair: a preview action that is allowed and an apply action that asks for confirmation. Runnable actions accept only whitelisted parameters, never free SQL or a file path. **Step 4: put the hard rules outside the prompt** A rule an AI agent must not break belongs in a mechanism, not in a sentence. An agent's context is its prompt. We once told a person that an agent would not copy material from a published database, then wrote the agent's prompt without that prohibition. The agent did exactly that, efficiently. A rule stated anywhere other than the agent's instructions does not exist for the agent. So the hard rules live in three places. Database access goes through MCP servers (Model Context Protocol, the interface through which the agent calls tools) that run every query inside a READ ONLY transaction. A PreToolUse hook, a script the agent runtime calls before each tool use, blocks catastrophic commands outright and asks for confirmation before destructive SQL or a `git push`; the team can add patterns that apply to everyone from the next session. A second pair of hooks logs skill and database usage to the shared knowledge base after every turn, so the activity record does not depend on anyone remembering. A guard that matches command text is blunt. Ours refused a harmless search that merely contained the name of a database dump utility. We treat that as deliberate and read the schema from `information_schema` instead of arguing with the guard. **Step 5: memory the next agent can reason with** The shared knowledge base holds what every agent needs: secrets, which live nowhere else, the activity log, a register of files, reference links and the skill register. Personal memory is a folder of small Markdown files, one fact per file, each with a rule, a **Why:** line and a **How to apply:** line. The Why line lets a later agent judge an edge case instead of applying the rule blindly. An index lists one line per note, because the index is read with a size limit of about 17 KB. Reusable lessons move from personal memory to the team store, after a search for an existing version. A fact that changes is marked stale, not deleted. Links between notes go through a gate: in our test, linking on embedding similarity alone scored 24.9% F1 against 23.9% for random links, and 31.6% with a gate that approves only specific relations. **Consolidation audits: contradictions first** A skill consolidation audit fans out read-only agents, one per cluster of skills, and asks each for overlaps and, explicitly, for contradictions. Contradictions are fixed first, because they produce wrong numbers. We found breakeven ROAS defined as `1 / margin` in one skill and `1 / (margin × delivery rate)` in another for cash-on-delivery stores, a creative-fatigue threshold of 10% in one place and 20% in another, and four definitions of cost of goods. Pure duplicates are deleted after their references are repointed. Several script-backed skills are merged into one skill with a thin dispatcher, without rewriting the tested scripts, and each mode is tested on real data before the originals go. A skill with a unique script is never deleted. A deletion has to reach three stores: the repository, the catalogue database and the skill server. The skill server has no delete, so the old name is republished as a tombstone pointing to its replacement. **Where we were wrong** **The hooks were off.** On one machine a settings flag disabled all hooks, so the guardrail and the logging our instructions described as guaranteed never ran. We re-enabled them after checking the guard: a harmless `echo` passed and a recursive delete of the root was denied. The documentation also says a PreToolUse deny is ignored in bypass-permissions mode. We have not verified that ourselves, so for unattended agents the defence is at the operating-system level. **The errors were not broken skills.** Of 182 recorded skill errors, 91 came from one lookup that reported a 404 as a failure, when *not found* was the answer. The most common cause of an apparent outage was an optional secret declared as required: 57 such cases in 32 skills, with 31 removed from 15 manifests. The server also returned a bare 500 for calling mistakes; it now returns 400, 403 or 503 with the reason, verified on 5 paths. A static manifest audit found 104 mismatches in 37 skills, some of them false positives, because the audit does not understand subcommands. **Fixes that never ran.** Code that never executes looks identical to code that runs and finds nothing. Before changing a rule we now ask for two numbers: how many times it ran, and on how many cases it produced a result. One address rule resolved 0 of 2,258 rejected orders before a one-character fix and 47 after. **Limits and checklist** None of this makes an agent correct. Skills make good practice repeatable, and they make mistakes repeatable too. A dry run shows what would change, not whether it should. Hooks protect only the machines where they run, and only in the modes that honour them. **Checklist.** Search the catalogue and extend where you can. Write a description with real trigger phrases. Declare dependencies inline and run with uv. Dry run by default, apply on request. Read-only database access. Hard rules in hooks, not in prose. A Why line on every memory note. After publishing, compare the scripts SKILL.md mentions with the files actually committed. Audit for contradictions before duplicates. ### How to calculate breakeven CPA for a cash-on-delivery store Breakeven CPA is the most a store can pay in advertising for one placed order before that order loses money. For a cash-on-delivery store it is revenue without VAT, minus goods and shipping, counted only on delivered orders, minus the shipping already paid on every refused parcel. We scale only below 70% of that number. **What breakeven CPA means for a cash-on-delivery store** Breakeven CPA (cost per acquisition) for a cash-on-delivery store is the advertising cost per placed order at which the order leaves exactly zero contribution. The formula is `breakeven = (AOV ÷ (1 + VAT) − goods − shipping) × delivered − shipping × (1 − delivered)`. Pay more than breakeven CPA per placed order and every order loses money; pay less and every order earns. In that formula, AOV is the average order value including VAT, goods is the cost of goods in one order, shipping is the courier cost of one parcel, and delivered is the share of placed orders that customers actually accept and pay for. Cash on delivery means the customer pays the courier when the parcel arrives. Until then a cash-on-delivery order is a promise, and some customers refuse the parcel at the door. We publish this breakeven formula as a calculator on our site. It is the same arithmetic our profitability engine runs per store and per SKU before anyone touches an advertising budget. **The four inputs, and where each one should come from** **1. Revenue without VAT.** Divide AOV by (1 + VAT). VAT is collected for the state and is not the store's money. For cross-border orders use the VAT rate of the country the parcel is delivered to. Enter goods and shipping without VAT, because the VAT on those costs is deductible. **2. Cost of goods, from real order lines.** Multiply every line in every order by the unit cost of that product. Do not use a flat catalogue margin. A 2+1 promotion ships three units for the price of two, so the real share of goods in revenue is higher than the catalogue suggests. **3. Shipping per parcel, without VAT, as a median.** Courier invoices include VAT, so divide it out. Use the median parcel cost rather than the average, because a few heavy parcels pull the average up. If the courier bills the cash-collection fee separately, add it to shipping. **4. Delivered share, from courier statuses.** Count parcels the courier marks as delivered. Do not use the payment status in the shop: on cash-on-delivery orders the payment status often stays pending long after the parcel was delivered, so it cannot tell delivered from refused. Measure over a window long enough for recent parcels to finish their journey. **Why refused parcels change the breakeven CPA** The last term of the breakeven formula, `shipping × (1 − delivered)`, is the cost of refused parcels. A refused parcel brings no revenue, but the courier was still paid to drive it to the door. Most breakeven calculators leave this term out, and that omission is exactly what turns a losing campaign into a profitable one on paper. Where the courier does not bill the return, count one leg, the outbound trip. When a courier also bills the return leg, as is common on cross-border shipments, each refused parcel costs more and the refusal term grows. Read the courier contract rather than assuming. The delivered share also multiplies the whole contribution, not only shipping. A store whose delivery rate falls loses on both sides at once: fewer orders that pay, and more parcels that cost. **A worked example with hypothetical numbers** The numbers below are round and hypothetical, chosen so the arithmetic is easy to follow. They do not come from any store we run. Inputs: AOV 200 including VAT, VAT 25%, goods 60 per order, shipping 20 per parcel, 80% of placed orders delivered. **Step 1.** Revenue without VAT: 200 ÷ 1.25 = 160. **Step 2.** Contribution per delivered order: 160 − 60 − 20 = 80. This is what one delivered order leaves before marketing. **Step 3.** Weighted by delivery: 80 × 0.8 = 64. **Step 4.** Refused parcels: 20 × 0.2 = 4. Breakeven CPA = 64 − 4 = 60 per placed order. **Step 5.** Scale CPA = 60 × 0.7 = 42. Breakeven ROAS, for platforms that report return on ad spend, is AOV ÷ breakeven CPA = 200 ÷ 60, about 3.3. A naive calculation that ignores VAT and refusals gives 200 − 60 − 20 = 120, double the real ceiling. A campaign running at a CPA of 90 looks comfortably profitable against 120 and loses money on every placed order against 60. **Placed orders or delivered orders: which CPA to compare** Ad platforms record a purchase when the order is placed. They do not learn that a parcel was later refused. The CPA a platform reports is therefore a cost per placed order, and the breakeven CPA above is also per placed order, which is why those two numbers can be compared directly. If you compute CPA from delivered orders instead (spend ÷ delivered orders), compare it with breakeven ÷ delivered share. In the hypothetical example that is 60 ÷ 0.8 = 75 per delivered order. Comparing a per-delivered CPA with a per-placed breakeven makes healthy campaigns look like losses. Profit itself is counted on delivered orders only, because only delivered cash-on-delivery orders produce revenue. The delivered share inside the formula already does this; do not add placed-order revenue anywhere else. **Scale CPA: why we keep 30% in reserve** Scale CPA is 70% of breakeven CPA. Below scale CPA we add budget; between scale CPA and breakeven CPA we hold; above breakeven CPA we cut. The 30% gap absorbs what the inputs get wrong: a delivery rate that drops next month, a courier price change, a cost of goods taken from an old invoice, and the fact that the extra orders bought with extra budget cost more than the average order. The 30% reserve is a choice, not a law. A store with very stable inputs could run closer to breakeven; a store whose refusal rate swings should keep a larger reserve. **Where we were wrong** **We charged refused domestic parcels twice.** Our first model counted a refused parcel as an outbound and a return trip. Where the return is not billed, that double count made the model understate breakeven CPA. The return leg belongs in the formula only where the contract bills it. **We trusted catalogue cost of goods.** The catalogue assumed one unit per order. With multi-unit promotions, the share of goods computed from real order lines was higher, so the true breakeven CPA was lower than the one we had been using. **We compared numbers in different currencies.** A breakeven CPA computed in our home currency, compared with a CPA reported in an ad account's own currency, turned profitable campaigns into apparent losses. Convert before every comparison. **Limits, and a short checklist** Breakeven CPA is a planning ceiling built from averages, not a measurement of realised profit. Breakeven CPA leaves out fixed costs such as salaries, rent, software and agency fees, so a store can sit under breakeven on every campaign and still lose money overall. Breakeven CPA assumes that new orders will be delivered at the rate of past orders, which fails when a new product or audience refuses more often. Breakeven CPA also describes the average order, not the marginal one. Checklist: revenue without VAT, at the destination country's rate; goods from real order lines; shipping as a median without VAT; delivered share from courier statuses, not payment status; refused parcels charged the legs the courier actually bills; platform CPA compared with breakeven per placed order, in the same currency; scale only below 70% of breakeven. ### How to judge ad campaigns on profit, not on target CPA To decide whether to scale, hold or cut an ad campaign, compare its cost per order with the store's breakeven CPA, not with a target CPA someone chose. Platform-reported ROAS is inflated, so CPA is the safer gate; brand search and ads on sold-out products need their own tests, because both look different in the report than in the bank account. **Why target CPA is the wrong gate for scale or cut decisions** To judge an ad campaign on profit, compare its cost per acquisition (CPA) with the store's breakeven CPA: the most one order can cost in advertising before it loses money. A target CPA is an efficiency goal that someone chose; breakeven CPA is a property of the store's prices, costs and delivery rate. We use three zones: CPA at or below 70% of breakeven means scale, CPA between that and breakeven means hold, CPA above breakeven means cut. We learned this from our own mistake. We had set aggressive target CPAs per store and judged every campaign against them. Performance Max campaigns, which find new customers at a higher CPA, were labelled expensive while they sat well under breakeven. Re-run against breakeven, campaigns marked expensive became scale candidates, and one campaign was confirmed as a real loss. Target CPA still has a job. On non-brand search it is a soft efficiency goal that disciplines spend. It is not a ceiling for deciding the fate of a profitable campaign. **The three zones: SCALE, HOLD, CUT** **SCALE** when CPA is at or below scale CPA, which is 70% of breakeven CPA. The 30% reserve absorbs errors in the inputs and the higher cost of the extra orders that extra budget buys. **HOLD** when CPA is between scale CPA and breakeven. **CUT** when CPA is above breakeven. Breakeven CPA itself is calculated separately, with VAT removed, only delivered orders counted and refused parcels charged their shipping. Breakeven is a ceiling, not a goal. When another channel buys the same customer more cheaply, the cheaper channel deserves the next unit of budget. We scale a search channel while its CPA stays below what the same store pays per order on social; once the two meet, moving budget gains nothing. Where a store runs only one channel, breakeven is the benchmark. **Why platform-reported ROAS is inflated, and why CPA is the safer gate** ROAS (return on ad spend) is the revenue an ad platform attributes to its ads, divided by spend. The platform decides what to attribute to itself. When we compared the ROAS reported by the ad platform with the revenue analytics assigned to the same platform's channels over 30 days, the reported figure was 1.4 to 1.8 times higher across stores. Three causes were configuration: two purchase actions counted for one order; micro-conversions such as add-to-cart or page view set as bidding goals; and a shop-app conversion action that credits the platform generously. Removing the first two lowered the inflation but did not remove it. CPA is more robust because its numerator, spend, is counted carefully by the platform, which invoices from it. Only the denominator can be wrong, and the denominator can be checked against real orders in the store's own records. One more check matters for cash-on-delivery stores: custom order forms can bypass the native checkout, so the platform sees zero purchases while real orders arrive. Confirm that purchases are tracked at all before reading any CPA. **Four traps that reverse a verdict** **1. Currency.** A breakeven CPA in the home currency compared with a CPA in the ad account's own currency turned profitable campaigns into apparent losses. Convert first. **2. The long window.** A 30-day average hides both recovery and saturation. One campaign judged CUT on 30 days had already recovered; another judged SCALE was saturating as we scaled it. Check the recent trend for every campaign, not only the ones marked CUT. **3. Average versus marginal.** In one account a 37% increase in spend bought 12% more orders. The average CPA still sat under breakeven, yet each extra order cost more than breakeven. Before scaling further, divide the change in spend by the change in orders; if that marginal cost is above breakeven, stop, whatever the average says. **4. Conversion lag.** Platforms attribute conversions late, so the last 1 to 2 days always look expensive. Our recent window runs from day −8 to day −3. On one day the platform reported a CPA above breakeven for what real orders showed was the best day in two weeks. Daily noise is large too: one store went from 55 to 111 orders a day within two weeks with no change in strategy. One day is never a signal. **Brand search: the best ROAS in the account, and often little profit** A brand search campaign bids on the store's own name. Brand search reports the best ROAS in the account because the people clicking were already looking for the store. The question for brand search is incrementality: what happens to total search, paid plus organic, when the campaign stops. The signature of cannibalisation in Search Console is stable brand impressions, a stable organic position around 1.1 and a collapsing organic click-through rate. In one account the organic click-through rate on brand queries fell from 57.5% to 22.0% while paid brand clicks rose. When that account was suspended, the organic click-through rate rose to 74.6% within one week, and no competitor took the traffic. Our test is to pause the brand campaign rather than lower its budget, then compare total search revenue in analytics after 5 to 7 days. In one store total search revenue stayed flat while brand spend went to zero. In another, organic did not take over and total search fell, so those brand ads were incremental and we recommended turning them back on. A third account had real competitors bidding on its name: 25.5% impression share, 63% lost to rank, an organic click-through rate that had never been high. There the brand campaign stays. Brand ads are waste only when organic click-through on the brand is high and nobody else is bidding. **Ads on out-of-stock products: what the real waste is** Ad platforms do not pause an ad when a product sells out, and nothing in our stack linked stock to ads automatically. On the tracked part of one catalogue, ads kept running a median of 2 days after a product reached zero stock, at most 15. Zero stock in the shop is not the same as cannot be sold. Pages at zero often still convert through backorder, made-to-order or deliberate overselling, and stock can sit in a second warehouse the shop does not see. A flag on every paid visit to a zero-stock page therefore overstates the waste. The real waste is paid traffic to pages with no key events and no revenue. In our check that waste was small and concentrated, mostly duplicate product URLs left in the feed: a catalogue hygiene problem, not a budget problem. Our first two lists of ads on sold-out products were false positives. Spend was recorded on the parent SKU while stock was tracked on its variants, and shop inventory did not match physical stock. Of about 380 advertised SKUs, 3 were sold out everywhere and set to stop selling, and the ads on all 3 had already been stopped. **Where we were wrong, and the limits of the method** **Targets applied to new campaigns starved them.** A low target CPA on a campaign with few conversions cut its delivery almost to nothing. We now set a target only after 15 to 30 conversions; before that the campaign maximises conversions under a budget cap. **We trusted our own verdict tool.** Each of the four traps above reversed a verdict rather than nudging it, and we acted on verdicts because our own script produced them. An extreme verdict, such as a brand campaign far above breakeven, is now treated as a sign the tool is wrong until checked. Limits: breakeven excludes fixed costs, so a portfolio of profitable campaigns can still sit inside a loss-making business. Analytics revenue is not incremental either; only a pause or a geographic test measures incrementality, and in a small account even a pause can be inconclusive. **A short checklist** Before scaling or cutting: same currency; CPA per placed order against breakeven per placed order; recent window that skips the last 2 days; marginal cost if budget rose recently; purchases actually tracked; reported orders cross-checked with the store's records. For brand search: test by pausing and read total search revenue. For sold-out products: look for pages with paid traffic, no key events and no revenue. ### How to catch silent data failures: freshness watchdogs, heartbeats and reconciliation A silent data failure is a pipeline that reports success while its data is stale or wrong. We catch them with three scheduled checks: a watchdog that reads the newest row instead of the job status, heartbeats that fire only on success, and a daily reconciliation of the same number from two independent sources. The checks themselves were wrong several times, and those mistakes are part of the method. **What a silent data failure is** A silent data failure is a pipeline that keeps reporting success while the data it produces is stale, partial or wrong. You catch silent data failures by checking the **output** of each pipeline (the newest row in the destination table, the count of records produced today) against a deadline, by making every scheduled job send a heartbeat only when it succeeds, and by reconciling the same figure from two independent sources every day. Our costliest data failures were not crashes. A profitability table went unsynchronised for **23** days and the monthly figures built on it were wrong for the whole period, while every job around it exited cleanly. A crash gets fixed the same hour because someone sees it. A false success gets read, believed and acted on. **Step 1: a freshness watchdog that reads the data, not the job status** A freshness watchdog is a scheduled script that asks, for every important table, when the data last moved, and compares the answer with a service-level deadline. The question is `MAX(date)` or `MAX(created_at)` in the destination table, never "did the job run". A job log is written when the sync fails too, so the age of the log proves nothing about the age of the data. **A plain freshness check is not enough.** In the 23-day incident the month *was* present in the table, because marketing cost came from a different source and kept the month alive. Only revenue and deliveries were zero. The check that catches it is a consistency rule: a current month with marketing cost above zero but revenue or deliveries at zero is a false profit-and-loss statement, not a quiet month. **Watch for absence, not only presence.** Our first version grouped the sync log by source, so a source that stopped completely simply vanished from the report. Declare the expected sources explicitly, give each a deadline (ours is **120** minutes for scheduled syncs) and treat total silence as critical. We proved the fix with a phantom source, which turned red. **Compare like with like on time.** A job once produced **414** shipping labels one day and **0** the next while the report stayed green; a whole-day threshold stays silent early in the morning, when the watchdog runs. The check now compares today up to the current hour with the average of the last **7** days up to the same hour. **Step 2: heartbeats, the dead-man switch for jobs that never start** A heartbeat is a ping that a scheduled job sends at the end of a *successful* run; a separate check flags any job whose last ping is overdue. Heartbeats catch the failure a freshness check cannot see: a job that never starts writes nothing at all, not even a failure line. We wired heartbeats onto **9** pipeline jobs and flag a job as overdue after twice its interval plus **30** minutes. The job that created every shipping label had no heartbeat, although **20** smaller jobs did. Check that the most important job is on the list before trusting the list. **Know your exit codes before you write `&& heartbeat`.** An audit script of ours exited with code 1 when it found deviations, its normal outcome, so a heartbeat chained with `&&` almost never fired. The watchdog called a perfectly healthy job critical for **8** days. We now accept codes 0 and 1 for that job and reserve a distinct code for "did not run, lock held". **Step 3: reconcile the same number from two independent sources** Reconciliation means computing the same quantity from two systems that do not share an input, and alerting when they drift apart. We reconcile delivered orders as counted by the profitability engine against the shipping records, and marketing cost as recorded in a spreadsheet against the data warehouse. Delivered orders were the metric that had been undercounted **1.6**× in an earlier month, and a count of deliveries is independent of currency and tax rules, so drift in it is easy to read. On the first run, delivered orders matched within **2**% for every business line. **Alert on the transition, not the state.** Closed months that drifted in the past stay above the threshold forever. Re-sending them daily is spam, and spam teaches people to ignore the channel. The reconciliation stores every snapshot and emails only when a key moves from OK to drift; the first observation of a new key is a silent baseline. **The traps that look like success** **An HTTP 200 on acceptance is not delivery.** A social posting job logged HTTP 200 on every run while **17** of **17** posts failed on one network. The 200 meant "accepted"; publishing happened later and failed per destination, because a cache of connected accounts had no expiry. Read the final status per destination. **A 200 from your own webhook proves only that the handler did not throw.** We declared a webhook-to-queue chain verified using a row we had inserted by hand. The new handler never committed, so real webhooks got 200 and the queue stayed empty. Send real traffic in at the upstream end and count rows at the downstream end. **An empty result has to say whether it searched.** A lookup queried a second source only with the keys returned by the first, which covered about 90% of the volume. On **106,049** orders the first source did not know, the function answered "searched, nothing found" with a success flag. Empty may mean "does not exist" only if every source that could know was asked; otherwise the answer is "unknown". **Every failure word can contain a success word.** A status matcher checked substrings in order, success first. In Romanian the word for "not delivered" contains the word for "delivered", and in English "undelivered" contains "delivered", so failed parcels came out as delivered and final. Check negations first, and gate the matcher with a test file; ours has **21** cases and runs on every deploy. **A table that exists is not a feature that works.** A screen, its table and a CHECK constraint were all in place, yet the table had **0** rows, a key column was empty on all **194** records and cost was empty on all **3,920** products. Before fixing a screen, ask who *writes* to it: `COUNT(*)` on the table, `COUNT(column)` on each column. **One expiring credential can stop a whole platform.** Every advertising account on one platform synced through a single OAuth token lasting about **60** days. When it expired, all accounts froze on the same day while the error log named one account at random, and the token stayed dead for **11** days. Alert on expiry in advance and prefer credentials that do not expire. **Where we were wrong** **Our fix made a loop invisible.** A sync loop rewrote about **41,000** unchanged rows every cycle and logged its summary only when something was written. We stopped the waste, a healthy cycle then wrote zero rows, and the loop vanished from the log. A review caught it, not us. The summary also could not tell "nothing new" from "could not read": on one day **18** of **23** connections had expired keys and returned zero orders without an exception. The heartbeat line is now always written, with a separate counter for empty sources. **We measured the length of an error.** To avoid printing a secret, we masked the output of a secret lookup and counted characters. There were **63**, a plausible length for a credential. They were the error message saying the knowledge base was unreachable. Check the exit code and stderr; never measure a property that is equally compatible with success and failure. **Our watchdog was wrong more often than the automations.** In one day, three red checks were all false. One filtered on the date a backfill wrote the rows instead of the event date and reported **2**% coverage where the truth was **99**%; another demanded an effect before an automation's deliberate **120**-minute grace period had passed. We split the checks rather than loosen them: a checker that cannot fail is not a checker. **A backfill would have overwritten human decisions.** It selected rows whose value differed from the target instead of rows with no value, so every run after the first would silently convert choices a person had made. Backfills on a column the interface also writes must filter on `IS NULL`. **Limits of this method** Freshness, heartbeats and reconciliation catch data that stopped moving or drifted apart. They do not catch a figure that is fresh, consistent across sources, and wrong in both because both share a definition error. Reconciliation is only as strong as the independence of the two sources; if one source filters what the other is asked, the second inherits the first's gaps. Every threshold here was calibrated on our own history: the label check was backtested on **9** real days, and its first variant gave **3** false positives on a weekend. A quiet report says the checks are quiet, not that nothing can break. **Checklist** **1. Output.** For every table that feeds a decision, check `MAX(date)` against a deadline, and add a consistency rule for months that exist but are hollow. **2. Absence.** Declare the expected sources; total silence is critical. **3. Heartbeats.** Ping on success only, know each script's success exit codes, and make sure the most important job is wired. **4. Reconciliation.** Compare one quantity from two independent sources daily; alert on OK-to-drift transitions only. **5. Proof.** Verify chains end to end with real traffic, read final status per destination, and make empty results say whether they searched. **6. The checker.** Before declaring an automation dead, check the denominator, the event date and the grace period. ### Safe deploys: dry-run by default, parity checks and verifying what actually shipped A successful deploy command proves that a transfer happened, not what was transferred. We make deploys safe with three habits: every deploy script is a dry run unless given --apply, a daily parity check compares server files with git, and after publishing we compare the served source and response bodies with what we meant to ship. **What a safe deploy is** A safe deploy is one where you know, before and after, exactly what changed on the server. In practice that means three habits: every deploy script runs as a **dry run by default** and needs an explicit `--apply` to write; a **parity check** compares the files on the server with the committed source and complains about any difference; and after publishing you **verify what is actually being served**, by reading content, not status codes. A successful deploy command proves that a transfer happened. It does not prove what was transferred, whether the platform noticed it, or whether anything else on the server changed as a side effect. Every rule below came from an incident where the deploy reported success. **Step 1: dry run by default** A dry run is a mode in which the deploy script lists what it would copy and delete without touching the server. Our website deploy script builds the site from source first, then calls `rsync` with `--checksum --delete --dry-run` unless `--apply` is passed. Building first matters: the published files must come from the sources, not from whatever was left on disk by the last run. The `--delete` flag removes server files that no longer exist locally, which is exactly why the dry run is the default: read the list of deletions before you press the button. Our server-side deploy script for internal tools follows the same rule: dry run by default, a backup copy of every file it replaces, and `git pull --ff-only` so that the server never merges or rewrites history on its own. **Step 2: deploy only from a known, quiet tree** **A deploy publishes whatever is on disk, including other people's unfinished work.** One of our deploy scripts synchronised the whole working tree with `rsync --delete`. We deployed **six** times in one session and, without editing a single file of theirs, published the in-progress work of another AI agent working in parallel on the same project. Its database migration had already run in production. The build passed and the health check answered, so nothing signalled the problem, and the project was not under git, so there was no `git status` to warn us either. **Never deploy while a parallel workflow is writing to the source files.** One morning, two versions of our Google Workspace add-ons were published with the wrong content: one carried another add-on's panel file, the other a debugging probe visible in its panel. Parallel agents had been swapping files in and out of the same folder, and for a few seconds each file held something else. `clasp push` reported success, because the transfer worked. Before the first deploy into a shared project we now list the files modified today and check who wrote them, tell the other session before deploying rather than after, and check for collisions in migration numbering. The deploy script refuses to start if the app has uncommitted changes. **Step 3: parity between git and the server** A parity check compares each deployed file on the server with the same file in the main branch and reports any file that differs. Divergence between what git says and what runs is how most of our drift problems started: files copied by hand with `scp` that never went back into git, or fixes made on the server and lost at the next deploy. Our parity tool discovers the deployed files from the git tree, runs on a daily schedule and emails only when a file newly diverges. On its first run all **21** deployed files were identical, which was the baseline we wanted to protect. The rule that follows from it is simple: the correct deploy is the git-driven script, not a manual copy. **Step 4: verify what actually shipped** **Compare the served source with the local source, file by file.** After the add-on incident, our add-on deploy script fetches the code the platform is serving after the push and after the version is created, compares it with the local files, and stops before moving deployments if anything differs. On its first run the guard found a second contamination, on a different add-on, that we were not looking for. **Read the body, not the status code.** We once reported that an API description and a documentation page on one of our servers were public, based only on a 200 status. Both responses were the single-page application's index page, served as a fallback for every unknown path. Asked directly, the service answered 401. A status code says a server answered, not what it answered. **Check that the platform knows about the new code.** A scheduled function on a background-job platform was shipped after the last time the app had been registered with that platform. The code was in the deployed bundle, passed type checks and worked when called by hand, but it never ran on its schedule for **4** months. The **64** manual runs in the log were fully compatible with a function the scheduler had never heard of. Re-registering produced the first scheduled runs within **10** minutes; a step in the deploy pipeline now re-registers after every production deploy and fails loudly if the handshake is refused. **The shell traps that turned a safe step into a live change** **A test command can change production.** An agent tested a temporary web-server configuration as root with the system binary. The test run recreated the server's temporary directories and gave them to the default unprivileged user, because the test file did not name one. The real workers could no longer write request bodies, so every large incoming webhook got a 500 for about five hours: **100,893** order-update webhooks failed and **84** orders never arrived. The application log showed nothing, because the requests never reached the application; the clue was a 500 with a **186**-byte body, which is the web server's own error page. Test configurations now run in a throwaway container. **A semicolon does not inherit the failure of `&&`.** A one-line command chained `cd` into a new worktree with `&&`, then ran a patch after a `;`. Creating the worktree failed, the `cd` never happened, and the patch was applied to the live tree the deploy script publishes from. No deploy ran before we noticed and reverted it. Writing steps now live in a script with `set -euo pipefail` and a guard that checks the current directory before the first write. **`ssh` inside a loop eats the list.** Without `-n`, `ssh` reads standard input, so the first iteration swallowed the rest of the file. A job prepared **three** batches and sent only the first, with no error. Use `ssh -n`, read the loop from a separate file descriptor, and count at the end: a loop that processes fewer items than it was given must exit non-zero. Test loops with at least **2** items. **Code must never pass through a layer of shell quoting.** Sending a script through `ssh '...'` with a heredoc silently removed every single quote inside it; we hit this **three** times, the last time with SQL. Write the file locally, check its syntax, copy it with `scp`, then run it. **Where we were wrong** We told the reviewer that the debugging probe had not reached users, based on `git log`. It could not have shown up there: the probe was never committed. Absence from git is not evidence of absence from production. The evidence is the source the server is serving. Our own website deploy still ends by printing the status codes of **20** pages. For a static site a 404 is meaningful, but a 200 does not prove that the new content is there, which is the same lesson as the single-page fallback above. The honest description is that this step catches missing pages, not stale ones. **Limits of this method** Dry runs, parity and served-source checks tell you what changed and whether it matches git. They do not tell you that the code in git is correct, and they do not cover side effects outside the deployed files, such as a migration that already ran or a test command that changed directory ownership. A parity check also only covers files it knows about; configuration edited directly on the server is invisible to it unless it is tracked. **Checklist** **1. Dry run first.** Build from source, list copies and deletions, then pass `--apply`. **2. Quiet tree.** No parallel workflow writing to the source; no uncommitted changes; recently modified files checked. **3. Parity.** Server files match the main branch; deploy through the git-driven script, never a manual copy. **4. Served content.** Compare served source with local; read response bodies; confirm the platform registered new functions. **5. Shell hygiene.** `set -euo pipefail`, a directory guard before writes, `ssh -n` in loops, count processed items, no code through shell quoting, no test configurations on production hosts. ### Testing a website at phone width: why headless screenshots lie Headless Chrome will not lay a page out narrower than about 500 CSS pixels, so a 390-pixel screenshot is a crop of a wider page, not a phone. We test phone widths with device emulation over the Chrome DevTools Protocol and then measure the page instead of looking at it. Six traps on our own site were caught only that way. **The short answer** A headless browser screenshot at phone width is not a phone rendering. Headless Chrome, meaning Chrome running without a visible window, clamps the layout viewport (the width CSS lays the page out against) to about 500 CSS pixels. Ask for a 390-pixel window and you get a 390-pixel image of a 500-pixel layout. To test a website at phone width, use device emulation over the Chrome DevTools Protocol (CDP), which sets the layout viewport itself, and then measure the page rather than judge the image. The crop does two kinds of damage. Content cut off at the edge looks exactly like horizontal overflow, so you hunt a bug that does not exist. And every media query below 500 pixels never runs, which is where the real phone problems live. **Step 1: prove the viewport before trusting the image** Load a probe page that writes its own `window.innerWidth` and the root element's client width into the document as visible text, so the number ends up in the screenshot rather than in a console nobody reads. With a window size of 390, our probe printed 500 for both. The resize tool of a browser-automation server we use hit the same limit: asked for 1440, it returned 500. The fix is emulation, not a window size. In Puppeteer that is `page.emulate({ viewport: { width: 390, height: 844, deviceScaleFactor: 3, isMobile: true, hasTouch: true }, userAgent })`. Emulation sets the layout width, pixel density, touch and the mobile flag, so the media queries under the clamp finally execute. Re-run the probe after emulating; the number should now match what you asked for. **Step 2: measure, don't look** A screenshot answers whether the pixels look right. Most phone defects are about what is absent: a column pushed off screen, an image that never loaded, content that never became visible. So our harness loads each page at each viewport and reports numbers: horizontal overflow and the element that causes it, page height in screens, touch targets smaller than 44 pixels, images not rendered, JavaScript errors and responses with 4xx status. On our site that is 12 pages at 3 viewports. Two details make the measurement honest. Compare the document's scroll width with the viewport width with any overflow-hiding rule disabled, because hiding overflow removes the scrollbar, not the overflow. And count images by state, using the `complete` flag after the page settles, not by eye. **The six traps we measured on our own site** **1. The headless floor.** The 500-pixel clamp described above. Every other check is meaningless until it is ruled out. **2. Lazy images inside overflow: hidden.** A marquee is a track wider than its container, moved sideways with a transform, inside `overflow: hidden`. Images pushed outside never enter the viewport, so a browser that honours `loading="lazy"` correctly never loads them. In one strip, 39 of 56 images never loaded and showed as white squares. **3. Scroll-driven animation from opacity 0.** An element animated with `animation-timeline: view()` that starts at `opacity: 0` holds that state until its range begins. On a page too short to scroll, the range never begins and the content stays invisible. A scroll reveal now animates `transform` only. Position may depend on scroll; existence may not. **4. 1fr has a content minimum.** In CSS grid, `1fr` means `minmax(auto, 1fr)`, and the auto minimum is the longest unbreakable word. An email address pushed a whole footer column off screen at 390 pixels. The fix is `minmax(0, 1fr)`. A `body { overflow-x: hidden }` rule had been hiding the symptom; we removed it so the next such bug shows. **5. Smooth scroll in headless.** With `html { scroll-behavior: smooth }`, a scripted `window.scrollTo` did not take effect in headless, so tests that scroll and then measure measured the top of the page. Scroll with `behavior: 'instant'` in tests. **6. Specificity.** `.nav a` has specificity (0,1,1) and beats `.btn-primary` at (0,1,0), so the button in the header inherited the link colour. Nothing was broken in the layout; the colour was simply the wrong rule winning. **Emulation is not the phone's engine** Device emulation changes the viewport and the user agent. It does not change the engine that resolves the CSS. On a shop page built with a visual page builder, product titles overflowed onto the badge and the price only on iPhones. Desktop Chrome with mobile emulation, the builder's own editor and a native web view on a Mac all rendered the page correctly, so three environments said it was fine. The cause was `-webkit-line-clamp: 0`, an invalid value. Chrome quietly treats it as no clamp; iOS Safari applied it literally, collapsing the text box to one line while the text kept painting across several. What found it was not reproduction but counting on the live page: 42 text blocks had the mobile clamp unset and exactly 6 had 0, the same 6 that were broken. When a defect appears only on a device you do not have, make a histogram of the device-specific CSS values on the live page and look for the rare one. **Render in both themes and both motion regimes** Every visual check should run in every regime the page has: light and dark, reduced motion and normal. Our own rendering global rule that collapsed all animation durations under reduced motion also collapsed the animation that positioned items on a circle, so people who asked for less motion got every item stacked at one point. We saw it only because we rendered both regimes. Theme contrast fails the same way. In one of our Android apps, colours written directly in code did not follow the dark theme: 77 texts fell below the WCAG AA contrast ratio of 4.5:1 at night, the worst at 1.03:1. The mirror error appeared after the fix: a text colour that inverts between themes, used as a background under fixed white text, gave 1.18:1. The check that catches both computes contrast for every text and background pair in both themes. **Where we were wrong** We knew the lazy-image trap, had written it down, and reintroduced it in a second strip, where it was caught again at 8 of 28 images. Knowing the failure did not prevent it; the check did. The overflow-hiding rule that hid the footer bug was one we had written ourselves. And on the iPhone-only bug we spent time reproducing it in three environments before counting values on the live page, which is what found the cause. **Limits and checklist** The headless clamp is an implementation detail observed in August 2026, not a standard; re-run the probe instead of trusting our number. These checks detect absence: an unloaded image, an unsettled element, a page wider than its viewport. They do not say whether the layout is good, and a check covers only what it measures. Our own latency instrument once timed 7% of a request and reported the whole request as fast. A person holding a real phone still has to look. **Checklist.** Prove the viewport with a probe. Emulate the device over CDP; never trust a window size in headless. Measure overflow with overflow hiding off. Count unloaded images inside sideways-scrolling containers. Animate transform, never opacity, on scroll. Use `minmax(0, 1fr)` where content can be long. Scroll instantly in tests. Render light and dark, reduced motion and normal. For a device-only bug, count CSS values on the live page. ### How Shopify Admin API tokens expire and refresh: permanent tokens, expiring tokens and the refresh traps Shopify Admin API tokens come in kinds that must be handled differently: a permanent custom-app token must never go through refresh logic, while an expiring token must be refreshed with a single-use refresh token that you persist every time. Most of our token outages were not expired tokens but refresh logic applied to the wrong token, or two refreshes racing each other. **What kinds of Shopify Admin API tokens exist** A Shopify Admin API access token is the value a program sends in the `X-Shopify-Access-Token` header on every Admin API request. We meet three kinds in practice. A **permanent (non-expiring) offline token**, typically the `shpat_` token of a custom app, stays valid until the app is uninstalled or its secret is rotated. An **expiring offline token** lives 1 hour and comes with a **refresh token** (prefix `shprt_`) that lives 90 days and can be used only once. A **minted token** is created on demand from an app's client credentials and lives about 24 hours, so storing it is pointless. Shopify also issues online tokens, tied to a logged-in user and valid for 24 hours at most; we do not use them for background work. The whole method fits in one rule: **a permanent token must never pass through refresh code, and an expiring token must always be refreshed through exactly one careful path that saves the new refresh token.** **Step 1: resolve the token type before every call** Every Shopify store we reach from code has one row in a store registry, and the token column holds either a static token or a **self-describing marker** that names the two credentials from which a token is minted on demand. One resolver function reads the registry, recognises the marker, mints when needed and returns a working token. Scripts never read the raw registry themselves. We learned this the hard way. Six stores answered `401 Invalid API key or access token` from our scripts, and the reflex was “the token expired, renew it”. Nothing had expired. The scripts were reading the registry directly and sending the marker text as if it were a token. The fix was in the resolver, and later we found a second tool with its own copy of the registry reader making the same mistake, so the fix had to go into every copy. A 401 from Shopify usually means *wrong kind of token for this store* before it means *dead token*. Tokens are secrets: they live only in the secrets vault, are piped into the process, and are never printed. When a log must identify one, it shows the last 4 characters. **Step 2: leave permanent tokens alone** A permanent `shpat_` token from a custom app has no refresh token and no expiry. If a token manager written for expiring tokens handles it, the manager will eventually try to refresh it, fail, and may mark the store as broken. Our rule: a permanent custom-app token is never passed through OAuth refresh, and if the token store requires an expiry date, it gets one far in the future so the refresh branch can never fire. Custom apps are exempt from Shopify's expiring-token requirement; the exemption ends when the same app is distributed publicly. **Step 3: request and refresh expiring tokens correctly** Shopify returns an expiring offline token only when the request asks for it with `expiring=1`, in both the token-exchange flow and the authorization-code flow. Both requests are form-encoded, not JSON. Without the parameter Shopify returns a non-expiring token, which is the documented default. Under Shopify's published rule, public apps created on or after 1 April 2026 must use expiring offline tokens and older public apps must migrate by 1 January 2027. Selecting public distribution switched the enforcement on immediately for one of our apps: the same token that returned 200 earlier started returning `403 Non-expiring access tokens are no longer accepted for the Admin API` on every call. Across our app fleet, 9 of 12 apps healed when we deleted the stored offline session and reloaded the app so it minted an expiring token; the other 3 were missing a single configuration flag and kept minting non-expiring tokens until the flag was added. A redeploy alone does not heal a non-expiring token, because the library treats it as valid forever and never exchanges it again. **Persist the refresh token on every refresh.** Webhooks and scheduled jobs have no user session, so the refresh token is their only route back in. Each refresh consumes the old refresh token and returns a new one. A `401` on refresh means the grant is dead: stop and ask a person to reopen the app. A `5xx`, `429` or network error is transient: retry with the *same* refresh token, because Shopify replays the same response for 1 hour. We refresh when less than 10 minutes of validity remain. One more sharp edge: the exchange that migrates a legacy token to an expiring one revokes the old token at once, even if you intended the call as a test. Do the migration in one step and save the result. **The refresh traps we hit** **A read-only simulation burned a refresh token.** A test script forced its database sessions to read-only and then read Shopify through the production code path. The access token had 8 minutes left, under the 10-minute margin, so the code refreshed it. Shopify consumed the refresh token and issued a new one, and the script could not save it. Shopify's grace window saved the store: 7 minutes later the live system refreshed with the old refresh token and received the same new token. The fix is a process-level flag that forbids refreshing altogether; every read-only process runs with it, and only processes that write may refresh. **Two simultaneous refreshes deleted the new token.** One refresh succeeded and saved a new refresh token. One second later a second refresh, started in parallel with the old refresh token, received 401, and its error handler cleared the stored refresh token, which by then was the winner's new one. One hour later the access token expired and the store had no API access until someone opened the app from the store admin. The fix is a per-store lock: a token is cleared only under the lock, after re-reading the store and confirming that the stored refresh token is still the one the failed request used. **A cached HTTP client kept sending an old token.** Our client cache kept one client per store with the token baked into its headers. The cache compared tokens on the wrong object, so after a rotation in another task it decided nothing had changed, and for 73 minutes many stores answered 401 in the middle of their tokens' lifetimes. The fix compares the token in the live client's header with the stored one, and on a 401 refreshes once and retries once. The same incident taught a second lesson: one error handler turned the failure into a business decision (“this order is a draft, skip it”), and nine orders shipped without an item. A failed read must be logged as a failure, not as a conclusion. **Rate limits and backoff** The Admin GraphQL API uses a leaky bucket per app and store. On the standard plan the bucket holds 1000 points and refills at 100 points per second; a query costs 1 point by default and a mutation 10. Read `extensions.cost.throttleStatus` on every response, slow down when `currentlyAvailable` drops below about 200, and on HTTP 429 honour `Retry-After`. The REST Asset API for theme files is far stricter, at roughly 2 requests per second. Fetching theme assets with 12 concurrent workers got mass-throttled and silently dropped most files; at about 3 requests per second with retry and backoff, all 369 assets came back. Two checks belong next to the rate limiter. A mutation that returns `200` with a non-empty `userErrors` array did nothing, and no exception is raised. And a read-back right after writing a theme asset can return the old body for a few seconds, so our verifier retries the read up to 5 times with growing waits and reports “could not confirm” rather than “rejected”. **Where we were wrong** We treated every 401 as an expired token, when the most common cause was a token of the wrong kind or a placeholder sent as a token. We assumed that a redeploy would fix stale tokens across our app fleet, when only deleting the stored session did. And after the refresh race and the read-only burn were fixed in production, we kept warning colleagues about them as live risks; before warning, we now check the version that is actually running. **Limits of this method** The token lifetimes and deadlines above are Shopify's published values at the time of writing and can change; check the current documentation before relying on them. We observed the grace window in one incident only. A per-store lock prevents races inside our own system, not between two different systems holding the same app's credentials. And none of this helps when the merchant uninstalls the app: then the only fix is a new installation. **Checklist** **1.** One resolver returns a working token; scripts never read the registry directly. **2.** Permanent custom-app tokens never enter refresh code. **3.** Request expiring tokens with `expiring=1` in both flows. **4.** Save the new refresh token on every refresh; retry transient errors with the same one. **5.** Read-only processes are forbidden from refreshing. **6.** Clear a token only under a per-store lock, after re-reading it. **7.** On 401, refresh once and retry once, then fail loudly. **8.** Respect the bucket, read `userErrors`, and verify writes by reading back with retries. ### How to deploy Google Apps Script code through the API instead of copy-paste Google Apps Script code can be read and written through the Apps Script API, so a script no longer has to be pasted into the editor by hand. Our deploy tool runs as a dry run by default, backs up the current project before every write, re-sends every file because the API replaces the whole project, and verifies the result twice: by reading the code back and by checking what the script wrote. **What deploying Apps Script through the API means** You can replace copy-paste into the Google Apps Script editor with three calls to the **Apps Script API**: read the project with `projects.getContent`, change the files locally, and write them back with `projects.updateContent`. Done safely, every write is preceded by a dry run and a backup, re-sends all of the project's files, and ends with a read-back that proves the server now holds the code you meant to send. A Google Apps Script **project** is a set of files: script files, of type `SERVER_JS`, and a manifest called `appsscript`, of type JSON. A project is either **standalone** or **container-bound**, meaning attached to one Sheet, Doc or Form. Our scripts are mostly container-bound: they build daily reporting rows in business spreadsheets, and each fix used to mean opening the editor, finding the function and pasting over it, with no backup and no record of what changed. **The one-time setup** Our tool authenticates as a **service account**, a non-human Google identity with its own key, using **domain-wide delegation**, a Workspace setting that lets that service account act as a named user of the domain. Three things must be in place. First, in the Workspace admin console, domain-wide delegation for the service account with the `script.projects` scope. Second, the per-user setting *Google Apps Script API* switched on in the Apps Script settings of the account that owns the scripts. Third, the Apps Script API enabled in the cloud project that holds the service account. **Reading and writing are not symmetrical.** `getContent` works for the plain service account when the script is shared with it. `updateContent` fails with `403 User has not enabled the Apps Script API` unless the call impersonates an owner who has the per-user setting on. The “user” in that message is the impersonated account, not the service account, which is why the error is confusing the first time. Delegation also takes a few minutes to propagate after you save it; until then the error is `unauthorized_client`. For our Google Workspace add-ons we use `clasp`, Google's command-line tool for Apps Script, from a local folder under version control. The API tool described here is for scripts that live inside business spreadsheets owned by a Workspace account, where there is no local folder to push from. **Step 1: back up, then edit surgically** **1. Find the script ID.** Standalone scripts can be listed through Drive. A container-bound script does not show up there; its ID is in the editor under Project Settings. **2. Read and save the whole project.** The `get` command writes every file of the project to a timestamped JSON backup before anything else happens. That file is the rollback. **3. Edit the backed-up source, not a retyped copy.** We change code with exact string replacements and assert how many times each pattern matched, instead of rewriting the file. A syntax check follows: copy the file with a `.js` extension and run `node --check` on it. Formula strings inside the code need care: a spreadsheet with a non-US locale expects `;` as the argument separator, so formulas must not be reformatted on autopilot. **Step 2: lint for the bugs that already broke production** Before every push, a linter checks the new source for the 7 mistakes that have already produced wrong numbers in our reports. Two are errors and block the push. `getSheetById` does not exist in Apps Script, and its use was the whole cause of a script that “writes nothing”; the method that exists is `getSheetByGid`. The second error is a **bounded source range**, such as a range that ends at the last row counted when the script runs. Our daily row is created before that day's data is synchronised into the source tabs, so new data lands past the frozen limit and every metric reads zero. An open range, with no end row, picks the data up as it arrives. Five more rules are warnings: `SpreadsheetApp.flush()` on a large sheet, `ARRAYFORMULA` over whole columns, `LET` combined with array functions (it works on scalars only, and with arrays it returned zero), `setValue` called inside a loop instead of one `setValues`, and reading from the sheet inside a loop. The linter is not general advice; each rule is a bug we shipped. **Step 3: dry run, then write the whole project** `push` is a dry run unless given `--apply`: it shows which files would change and runs the linter. The important detail is that `updateContent` **replaces the entire project**. A raw call that sends only the changed script file deletes the manifest and every other file. Our tool therefore re-sends every existing file unchanged and swaps only the files named on the command line. The same rules apply to the other write commands. `create` makes a new project, standalone or bound to a spreadsheet, and is also a dry run by default. There is no delete in the Apps Script API, so `trash` moves the script file to the Drive bin, where it can be restored for about 30 days; it refuses any file that is not an Apps Script project, so it cannot bin a spreadsheet by mistake. **Step 4: verify by reading back, then by effect** After writing, the tool reads the project back and compares it with what it sent. A successful API call proves that a transfer happened, not what was transferred. We learned that on the `clasp` side, when two add-on versions were published with the wrong files because a parallel workflow was swapping files in the same folder at the moment of the push; the push reported success both times. Reading the code back does not prove that the script works, and the API cannot run it for us. We tried: `scripts.run`, `processes.list` and `deployments.list` all returned 403 with our delegation. Running remotely would require linking each production script to the service account's cloud project and adding an API-executable deployment to every one of them, which is too invasive. So the last check is **by effect**: after the trigger runs, a `verify` command reads the latest rows of the target tab and fails if a recent row has every metric at zero (the exact signature of the bounded-range bug), if any cell holds a formula error, or if bounded ranges remain in live formulas. `verify` alarms only on a row that is entirely zero, never on a single zero metric. A brand whose advertising on one channel is paused on purpose would otherwise raise a false alarm every day, and an alarm that fires every day is soon ignored. **The traps we hit** **Slow scripts were slow because of the sheet, not the code.** The report tab held about 9,400 rows with about 15 live formulas each, around 140k formulas recalculating together. Converting rows older than 3 days into plain values at the end of each run fixed the speed. **Fixes to an append-only script do not repair history.** One script appends a new row each day and never touches older rows, so a formula fix only affects rows created after the deploy; old rows keep the old values until they are rewritten deliberately. **Matching on an exact name fails silently.** When a data feed renamed a store, every sum keyed on the old exact name returned zero without an error. Matching on a prefix or a case-insensitive pattern catches both the old and the new name. **A number inflated only in the dashboard is a dashboard bug.** A single-day average fed a calculated field in our dashboard tool and inflated one figure about 3 times, while the sheet itself was correct. We first emptied sheet tabs looking for double counting that did not exist. **Where we were wrong, and the limits** We trusted the editor's “saved” and the API's success response as proof, and both times the proof was elsewhere: in the code the server holds and in the rows the script writes. We also assumed that remote execution would be a configuration detail; it turned out to require changing every production script, so we verify by effect instead. The method has limits. Verification by effect only happens after the next trigger run, so a broken deploy can live until then; the backup makes the rollback quick, not instant. Domain-wide delegation is a powerful grant and belongs only to a service account whose key stays in a secrets vault. And the linter catches only the bugs we have already met. **Checklist** **1.** Set up delegation, the owner's per-user API setting and the cloud API once. **2.** Back up the full project before every write. **3.** Edit with exact replacements and a syntax check. **4.** Lint for the bugs that already broke production. **5.** Dry run first; write with `--apply`, re-sending every file. **6.** Read the code back and compare. **7.** After the next run, verify the rows the script wrote. ### Fail-safe caches and backfills: never delete history before the new data arrives A fail-safe cache is one where a failed refresh leaves the previous data untouched: write with a pure upsert, never delete-then-reinsert, and retry the timeout exception your client library really raises. Backfills fill gaps with IS NULL instead of rewriting decisions, snapshots check themselves on read, and a value from today never answers a question about the past. **What a fail-safe cache or backfill is** A fail-safe cache or backfill is one where a failed run leaves the previous data exactly as it was. Never delete history and then re-insert it on the condition that the new pull succeeds; write with a pure upsert (insert, or update when the key already exists), so a pull that fails or times out changes nothing. A backfill should fill gaps (`WHERE column IS NULL`), not rewrite values that a person has already decided. We keep several derived tables that are rebuilt from external APIs on a schedule: daily advertising cost, cost per product, monthly profit per business line. The useful question is never whether a pull will fail, but what the table looks like after it does. **Rule 1: pure upsert, and retry the timeout you actually get** The tempting way to refresh a date range is `DELETE FROM cache WHERE day >= :start` followed by an insert of whatever the pull returned. It looks idempotent and it is fail-unsafe. If the pull returns nothing because a credential expired or the API timed out, the delete has already run and the history is gone. The fail-safe version is an upsert: `INSERT ... ON CONFLICT (key) DO UPDATE`. Rows the pull returns are refreshed; rows it does not return keep their last known value. A failed pull leaves yesterday's numbers, which are stale but correct, and a freshness check can flag them as stale. Deleted numbers cannot be flagged. They just look like zero. **Retry the exception the library really raises.** Every external call should sit inside a retry with backoff, and the retry must catch the right class. A Google API client library we use raises a raw `TimeoutError` on a slow response, not the `RequestException` from `requests` that a retry wrapper is usually written for. A retry that catches the wrong class reads as handled in review and never fires. Force a timeout in a test, read which class comes out, and catch that class by name. **Rule 2: a backfill fills gaps, it does not rewrite decisions** A backfill is a script that populates a new column for rows that already exist. The safe filter is `WHERE column IS NULL`: only rows that have no answer yet. The unsafe filter is `WHERE column != target`: every row that is not already at the value the script would choose. We wrote the unsafe version. The script assigned a category to existing records. On its first run the column was empty everywhere, so both filters selected the same rows and the result was right. But the form in the interface writes a category on every save, with a default. From the second run on, the backfill would have converted every choice a person had made in the meantime into the script's value. We proved it on a copy of production: we set two records by hand to two different categories, ran the backfill again, and both came out with the script's category. The script's own header said it was idempotent and could be run any number of times, which made a second run recommended rather than merely possible. **Idempotent is not the same as harmless.** An operation can converge to the same state on every run and still destroy information: the decision a person made between two runs. The test that catches a destructive backfill is not "run it twice in a row", which passes, but "a person chose something else, then the script ran again". **Rule 3: a snapshot must check itself on read** A snapshot is any value derived once and kept for reuse: a cached setting, a precomputed context, a materialised table. A snapshot that waits to be invalidated by the code that changes its source goes out of date on the first code path that forgets to invalidate it. We hit this in an Android app. An object written to keep every screen on the same colour theme held a themed context built once, when the process started. The theme switch in the menu changed the system setting but never refreshed that object. From the moment a person chose dark mode until the process died, backgrounds were dark and the text was dark too: a contrast of **1.02**:1 on about **25** screens. The fix makes the snapshot verify itself on every read: compare the mode it was built for with the mode requested now, and rebuild only when they differ. For a data cache the same rule means storing the input next to the cached value and comparing on read, so a forgotten invalidation produces a rebuild, not a wrong answer. **Rule 4: today's state does not tell you what was true then** A current value cannot answer a question about the past. We built a rule that held orders whose product had no stock, and reported **166** orders waiting for goods that did not exist. The objection came at once: those products were sold out now, but they had stock when the orders were placed. For almost every flagged product, later orders had shipped, in one case **122** hours after the first held order. Stock arrives in batches. Stock is a number about now; orders are events in the past. Without a stock history, the question that separates the cases is also about events: *did anything with this product ship after this order was placed?* If yes, the goods existed after the order and the hold has another cause. If no, the order is last in the queue. Applied in code, the list went from **166** to **26** orders, each one genuinely the last for its product. When a decision depends on past state, keep a dated snapshot of that state instead of reconstructing it from today. **The traps that return less instead of failing** **A cap justified by an old measurement.** Two pagination limits in one system each had a comment justifying them. One API refuses page **50** and above, a ceiling of **10,000** rows, and the pages run from newest to oldest, so for the largest store only the most recent days were ever read. Invoice coverage was **0**% between days **12** and **23**, while smaller stores that fit under the ceiling stayed flat at about **90**%: the control group that proved it. The other cap read **30** pages of **200**, with the comment "**6,000** orders, more than any **10**-day window". True when it was written; later one store had **9,966** orders in **10** days and the job lost **3,966** from the tail. A cap does not raise an error. It returns less, and less looks identical to "that is all there is". When pagination is ordered, what is lost is systematically the tail. We now write any "N pages, N rows, N days is enough" constant with the measured volume beside it and a runtime check that logs when the cap is reached. After the fix, the same job read **28,039** orders where it had read **10,000**. **A time threshold skips failures forever.** An importer took the file links posted in a chat group that were newer than the last file it had imported successfully. A link that failed once was never retried, because by the next run newer links had succeeded and the threshold had moved past it. Measured after **16** days of scheduled runs, **8** links out of **59** had never arrived. **Four** had never been tried; **four** had failed once, and **three** of those worked on a new attempt. A timestamp records how far you got, not what you did, so it is safe only when processing is all-or-nothing. The fix is a row per unit with its own state (to fetch, done, failed permanently), and retries read the state, not a date. A validity window stops retrying what is truly dead, here **10** days against the **7**-day life of the transfer service's links, and those links are still reported rather than forgotten. **Where we were wrong** Every case above started with code that carried a correct-looking justification: a comment explaining the cap, an idempotency claim in a header, a retry wrapper, a component built to prevent exactly the bug it caused. The justification is what stopped the next reader from checking. We now treat a comment that justifies a limit as a prompt to measure again, not as evidence. And when we checked our stock hold for false positives, we counted recent deliveries per product, but those parcels had left while there was stock: the same mistake twice in one day. **Limits of this method** Fail-safe means stale-but-flagged, not correct. A pure upsert keeps the last good value, so it needs a freshness check that turns red when the value stops moving; without one, an upsert cache hides an outage longer than a delete would. **Checklist** **1. Upsert.** No `DELETE` before a pull; deletions are a separate step after the pull is checked. **2. Retry.** Force a timeout in a test and catch the exception class that really comes out. **3. Backfill.** Filter on `IS NULL`, and test "a person chose something else, then the script ran again". **4. Snapshot.** Store the input next to the derived value and compare on every read. **5. Past questions.** Answer them with events or dated snapshots, never with today's value. **6. Caps.** Write the measured volume beside every limit and log when it is reached. **7. Units.** Give every item that can fail on its own a row with its own state. ### SQLite and database traps in small production apps The database traps that hurt small production apps are not about scale: a long read on SQLite blocks every writer, a filter on an encrypted column silently matches nothing, DISTINCT fails on a table with a json column, commit() and rollback() expire ORM objects even inside the error handler, and a fixed epsilon is too small for a float32 column. Each one passes a quick test and fails in production, usually without a clear error. **Which database traps hit small production apps** The most common database traps in a small production app are not about scale. A long read on SQLite blocks every writer, a `WHERE` on an encrypted column silently matches nothing, `SELECT DISTINCT` fails on a table that has a `json` column, ORM objects expire after `commit()` and `rollback()` (including inside the error handler), and a fixed epsilon guard is too small for a `float32` column. Each one passes a quick test and fails in production, usually without a clear error. We run small apps on SQLite and Postgres behind Python services that use SQLAlchemy. Each trap below cost us an incident or a wrong report. For each one we give the cause, how it showed up, and the rule we follow now. **Trap 1: a read-only query on SQLite can stop production** On SQLite in the classic rollback-journal mode, a reader holds a shared lock for as long as its query runs, and no writer can commit until the reader finishes. On Postgres a long `SELECT` does not block writers; on SQLite outside WAL (write-ahead logging) mode it can, every time. We learned this during an investigation that had an explicit read-only mandate. Two of our AI agents wrote a verification script and ran a long query against the live database file of a warehouse app. The database was not in WAL mode. While the query ran, the endpoint a warehouse station uses to claim a batch of work answered HTTP **500** "database is locked", and the station could not load its batch. Another session noticed, stopped the process, made a backup and switched the database to WAL permanently. **The rule now.** Never investigate on a live production SQLite file. Make a consistent copy with the online backup API (`sqlite3 live.db ".backup copy.db"`, or `connection.backup()` in Python) and work on the copy; the same API produces our nightly backups, because a plain file copy is not guaranteed to be consistent while the database is being written. If the live file must be read, open it read-only (`?mode=ro`) and check `PRAGMA journal_mode` first; if it is not `wal`, stop and ask. **Trap 2: an encrypted column cannot be filtered in SQL** When a column is encrypted in the application with a random initialisation vector (IV), the same plain text produces a different ciphertext every time. A SQL filter such as `WHERE zip = '-'` compares against ciphertext and can never match. The query does not fail; it returns zero rows, and zero rows looks exactly like "there are no such cases". We widened a selector to catch placeholder postal codes. The syntax passed, the run passed, and it reported **21** extra orders. The filter could never have worked: the postal code was encrypted in **19,390** of **19,549** rows over **7** days, and the extra orders were only the rows where the column was NULL. We confirmed the encryption was random rather than deterministic by checking that the most frequent ciphertext values each appeared once. **The rule now.** Before any `WHERE` on an address, phone or email field, check the model to see whether the column is an encrypted type. If it is, filter in application code after the ORM has decrypted the value, or use a blind index where one exists: a separate column holding a keyed hash of the normalised value, which supports equality lookups without revealing the value. Or normalise at entry, while the value is still plain text: we put the rule "a postal code with no digit is filler" in the webhook handler. **Trap 3: DISTINCT on an entity with a json column fails** In Postgres, `SELECT DISTINCT` needs an equality operator for every selected column, and the `json` type has none (`jsonb` does). An ORM query that selects a whole entity selects every column, so `select(Order).join(LineItem).where(...).distinct()` fails with `could not identify an equality operator for type json` whenever the orders table has any `json` column, whatever the `WHERE` clause says. The message names neither the column nor `DISTINCT`, and the join looks like the obvious culprit, since a product can appear on several lines of one order and that is why you asked for distinct rows in the first place. Calling `.unique()` on the result does not help: it removes duplicates in Python after the SQL has already failed. **The rule now.** Select the ids first, since `DISTINCT` on one scalar column works. Remove duplicates in Python while keeping order (`dict.fromkeys(ids)`), then load the entities by id in batches with `IN (...)`, eager-loading the relationships you need. **Trap 4: commit() and rollback() expire your objects, even in the error handler** In SQLAlchemy, by default, every object in a session is expired after `commit()` and after `rollback()`, and the next attribute access reloads it from the database. In an async session that implicit reload is not allowed and raises `MissingGreenlet`. A loop that commits once per item therefore works on the first item and fails from the second, even when the relationships were eager-loaded. The worse part is the error handler. Our cleanup loop logged the order's name in its `except` block. That attribute had expired too, so the handler raised a second exception from inside `except` and ended the whole run. The logs showed two tracebacks stuck together and no message of ours, and the effect was the opposite of the function's purpose: the tickets it was meant to close stayed open indefinitely. The same trap returned with `rollback()`. A job that marks cash-on-delivery orders as paid hit an expected error, rolled back, then read the order's name for the log. One session was shared by every store in the loop, so the error stopped every store. For **4** days no cash-on-delivery order was marked paid anywhere in the fleet, with a single warning per run. **The rule now.** In a loop that commits per item, do not hold ORM objects across the commit. Select the ids and the plain values needed for logging up front, reload each object fresh inside the iteration, and in `except` use only those plain values, never attributes of the object that just failed. Write with `UPDATE ... WHERE id` where possible, and give each independent unit (a store, a tenant) its own session, so one failure cannot stop the others. **Trap 5: a fixed epsilon calibrated for the wrong float** A rounding guard such as `ceil(x - 1e-9)` is correct for a `float64` value, whose error near one is around `2e-16`. It is wrong for a value read from a Postgres `real` column, which is `float32`: `0.1` comes back as `0.10000000149011612`, ten units give `1.0000000149`, and that error of `1.5e-8` is **15**× the guard. The ceiling is then `2`, not one. We found it when an order for a single box left with two shipping labels. Parcels-per-unit densities were stored in a `real` column, and the guard's comment said, correctly, that it existed to turn ten times `0.1` into one. That was true for the literal in the test and false for the value that actually arrived. Measured across the real map of **939** densities and quantities from **1** to **60**, **849** pairs on **136** products came out with an extra parcel, all at the round quantities in which sets are sold, which is exactly where people order. Each one meant shipping an empty box. The guard also has to be relative. A fixed threshold fails as the number grows: `0.3` as `float32` was off by `6e-7` at **50** units, so `15.0000006` became `16`. Rounding to six decimals fails the same way. We now take the tolerance from the error of the source (a `real` or `float4` column, a `numpy.float32`, JSON from a system written in C), make it relative, for example `max(1e-9, abs(x) * 1e-6)`, and keep one shared guard: the same guard existed in **three** copies and only **one** had been fixed when the bug was found. **Where we were wrong** In every case something reassured us: a read-only mandate, a run that returned rows, a comment explaining the guard, eager loading that should have made the attribute available. The reassurance is what stopped us checking. A query that returns zero rows, or a handful, is not evidence that a filter works; run the filter against a case you know exists. Our first verification of the float fix rewrote the rounding inside the test and confirmed a fix that was still wrong in **10** cases. A test must execute the function from the file that ships, not a copy of it. **Limits of these rules** These rules come from one stack: SQLite, Postgres and SQLAlchemy with async sessions. Other ORMs expire objects differently and other databases lock differently, so check the behaviour of yours. **Checklist** **1. SQLite.** Investigate on a copy made with the backup API; check `PRAGMA journal_mode` before touching a live file. **2. Encryption.** Check the column type before any `WHERE` on personal data; filter after decryption, through a blind index, or at entry. **3. DISTINCT.** Select ids, remove duplicates in Python, load entities in batches. **4. Sessions.** No ORM objects across `commit()` or `rollback()`; plain values in `except`; one session per independent unit. **5. Floats.** Take the tolerance from the source type and make it relative; one shared guard, tested from the shipped file. ### How to check product images before they go live: the traps in automated packshots and infographics Every serious product-image fault we found passed our automated checks: a clean packshot missing half the product, an infographic with correct dimensions drawn over another product, a sharp file that turned blurry once cropped. The method is to open every image and compare it with an independent reference for the physical product, not with other pictures from the same supplier pack. **What to check, and against what** To check an automated product image, open it and compare it with an independent reference for the physical product, such as the supplier's SKU reference, the label on the box or the item in the warehouse, rather than trusting the metrics the image pipeline reports. A **packshot** is a photo of the product alone on a plain, usually white, background; a **product infographic** is a product image with dimensions or features written on it. Both are now often produced or cleaned by scripts, and both fail in ways that numbers do not show. The faults below come from listing and relisting products across several online stores and from a warehouse scanning app that shows a product photo at picking. Each one looked correct to a script. **Anchor on the product, not on the pictures** One product had a title with one set of dimensions while its URL and file names carried another. We found the same item on a marketplace with a dimensioned technical drawing matching the second set, and, decisively, the same photo as our first image. We changed the dimensions everywhere. It was wrong: the supplier's reference showed that the photos belonged to a different product, and the drawing described that other product perfectly. The lesson is to ask whether a piece of evidence passes through the artefact under dispute. URL, file names, declared volume and the outside drawing all came from one supplier pack: four signals, one source. The closer the match (the very same image), the more surely it confirms the wrong thing. Anchor instead on something a marketing pack cannot copy: the box label with SKU, colour and barcode, or the physical product. A wrong brief poisons a search the same way: a first search on 6 tracks failed because we had described a single-pitch roof on a product with two. **Infographics: correct numbers over the wrong drawing** On one product the title, description and specification table all said the door slides, and 4 of 5 photos showed a sliding door. The fifth, the dimensions infographic, had all 11 dimensions correct and a rendering of a hinged double glass door with a handle: another product. A text audit finds nothing, because the numbers reconcile perfectly. Suppliers put your product's dimensions over a stock rendering, and only opening the image and looking at the mechanism reveals it. We then checked all 6 products in that category and found the same class of fault, including a sketch that dimensioned a door at 1.5 m when the specification said 0.61 m, a factor of 2.5. **Measure colour, do not judge it.** On neutral renderings, red minus blue above 25 reads as warm brown and below 15 as neutral taupe or grey. One gallery measured 28 to 35 while the supplier reference measured 12, so the difference was not lighting. **Never regenerate an image that carries text.** An AI image model will redraw the dimension labels. We repair the faulty region with texture taken from the same image and restore the original text pixels through a mask; the proof is a pixel difference of zero on the text areas. For recolouring we shift chroma in LAB colour space through a hue mask, with a 9 px morphological opening that spares thin dimension lines. Check the resulting hue, not only that the step ran: one extra pass caught grass pixels at the mask edge and turned a wall green. **Packshots: the cleaning step that amputates** Our packshot script cleaned a product-on-white image by keeping only the largest connected component and whitening the rest. On a shower set made of visually separate parts it cut off the column and the rain head and left only the mixer. It happened twice on the same product: once caught by an adversarial reviewer, once only because we looked at the image before uploading. The result looked perfect, clean and well framed, and it showed a different product. Our quality metrics (share of white, framing at 97%, edge variance) were all compatible with half the product missing; none asks whether the product is still whole. The rule now: count connected components in the source, and if there is more than one, do not use the component filter. Whiten only the near-white background, for example green channel at or above 244 set to 255, and frame all the content. **Resolution depends on the crop, not the file** We downloaded source images with a width parameter of 1100 although the originals on the store CDN were 2048 pixels wide. For a product that fills the frame, scaling to a 1200 px output meant enlarging 1.06 to 1.29 times, which nobody sees. One hero image came from an inset, the product on white placed over a lifestyle photo: 462 by 648 real pixels, enlarged 1.80 times, and the blur was obvious. Downloaded at 2048, the inset became 860 by 1206, 1.86 times more pixels, and the enlargement fell to 1.05. Download originals without a width parameter; they cost a few megabytes. Before upload, compute for each hero image the product's width in the source divided by the output width; above about 1.3, find another source. When normalising framing across a collection, we scale the product to 0.86 of the frame height, add a 3.5% buffer at the top so caps and spray heads are not cut, keep 2000 px and save JPEG at quality 95 without chroma subsampling. Many stores reuse one media file on many products; on one store we found 72 files across 54 products, one of them on 22. Renaming a shared file renames it everywhere, and a theme that references images by file name breaks. Back up every original first. **Variants and SKUs: the right photo for the right item** In the warehouse scanning app, red and blue variants of the same product showed the same photo, because the app read the product image rather than the variant image. The correct variant image already existed in a daily-synced table that nothing read. Of 164 SKUs with a variant image, 99 had been showing something else. Sizes of the same colour correctly share one photo, so a rule that splits everything that looks alike would break the good cases. Preferring the store's photo instead would have changed 745 products to fix 82. Our own measurement also misled us. Images passed through an app proxy, and cutting URLs at the question mark made all proxied images look identical: 2,673 SKUs apparently shared a photo, while the real figure, after decoding the proxy parameter, was 255 groups and 958 SKUs. One SKU can be two physical products. 178 of 1,933 SKUs with a barcode (9.2%) carry different barcodes in different stores, because simple catalogue numbers are reused across brands. Treat a SKU as an ambiguous key: resolve the product through the barcode, and refuse a write when the barcodes lead to different products. **Generated images and matching by image** For generated lifestyle or detail shots, pass 1 to 3 real reference photos so the model keeps the exact product; without them it invents one. Use generated images for scenes, never for dimension infographics or anything that must be measurable. To match the same product across two catalogues when barcodes are unreliable, we combine a perceptual hash (Hamming distance at or below 12 means a near-identical photo) with CLIP image embeddings (cosine similarity at or above 0.925 means the same product in a different photo). On white-background commodity goods CLIP scores different products around 0.92, so CLIP-only matches must also share 2 name tokens and pass an attribute gate on pack count, dimensions and wattage. After that tuning we saw no false positives, but recall was only 15 to 35% when both sides had re-shot their photos, so image matching supplements other methods rather than replacing them. **Where we were wrong, and the limits** We trusted evidence that passed through the disputed photos, we trusted metrics that never asked whether the product was whole, and we almost shipped a rule that would have broken correct images. In each case a person looking at the image caught what the checks missed. The method does not replace that look; it tells you where to look. Mismatched images also carry a policy risk. Google Merchant Center can restrict a whole account for misrepresentation. In our case the cause was fake urgency widgets and benefit badges copied onto the wrong product, such as a skin-safety badge on a pair of scissors, not photos. But the review rule is the same: you get one request in the warning window, so fix everything before you ask. **Checklist** **Before upload:** open every image; compare it with the supplier's SKU reference or the box label, not with other images from the same pack; check the mechanism and colour, not only the numbers; count connected components before any packshot cleaning; compute the enlargement ratio from the crop. **In the catalogue:** back up originals before renaming or reframing; use variant images for variants; treat SKUs as ambiguous; never regenerate images that carry dimensions. ### How to mine Google Ads search terms and negative keywords without cutting conversions Mine Google Ads search terms by grouping the real queries into words and short phrases (n-grams), add negative keywords only for patterns that are clearly outside what the store sells, and audit existing negatives for ones that block buyers. Zero conversions on a single search term is not proof of waste: on Shopping and Performance Max most sales come from a long tail of queries that each convert rarely. **What search term mining is, and what it is for** Search term mining means reading the Google Ads search terms report, the queries people actually typed before clicking an ad, and turning it into two lists: negative keywords that stop ads showing for irrelevant queries, and new keywords for converting queries that are not yet targeted. Search term mining is safe only when a negative keyword is added for a pattern that is off-topic for the store, not for every query that has not converted yet. Keywords are guesses; search terms are what Google matched those guesses to. A negative keyword tells Google not to show an ad when a query contains a given word or phrase, in broad, phrase or exact match. Negative keywords do not stretch to cover close variants the way positive keywords do, so a phrase negative written in the plural does not block the singular. That detail decides whether a negative removes waste or removes a winner. We mine search terms weekly. Our tools only report candidates. A person reviews the list, and every change runs as a dry run before it is applied. **Step 1: rank single search terms by wasted spend** **1. Pull the terms.** The search term view in the Google Ads API covers Search and Shopping queries. Performance Max exposes only category-level search term insights, not raw queries, so search term mining pays most on Search campaigns. **2. Weight by spend, not by count.** A search term with spend and zero conversions is a candidate for a negative. Rank candidates by how much they cost: a term with 1 or 2 clicks and no conversion may simply lack data. **3. Tag each candidate.** We tag candidates as competitor, generic (a single broad word) or junk (free, PDF, recipe, jobs). Brand terms are excluded from the negative candidates entirely. A one-word generic query is almost always too broad to convert, unless that word is the store's single core product. **4. Split brand from non-brand.** Brand queries are cheap and convert well because the searcher already wanted the store. Know the brand share of an account before reading its overall ROAS. **Step 2: group search terms into n-grams to see patterns** An n-gram is a run of n consecutive words inside a query. Our n-gram miner splits every search term into 1-, 2- and 3-word grams and adds up cost, clicks and conversions per gram. It drops linking words such as for, with and their Romanian equivalents, so single-word grams are not dominated by them, and it counts each search term only once per gram, so a query that repeats a word does not double its cost. The n-gram view finds what the per-term view misses: a word that appears in many queries, each too small to notice, which together spend with no conversions. A gram becomes a negative candidate only if it appears in at least 2 distinct search terms, has spent above a threshold, has zero conversions and contains no brand term. Sorted the other way, the same table lists grams that convert at a low CPA: candidates for exact-match keywords. Tokenising has to be Unicode-aware. Splitting on plain ASCII letters breaks words with Romanian, Czech, Polish, Hungarian or Bulgarian diacritics into fragments that match nothing. **Step 3: decide which candidates become negative keywords** **Off-topic patterns become negatives.** In one account 32% of search spend went to zero-conversion terms, spread over 1,220 terms. We applied 19 phrase negatives to three Shopping campaigns, covering product types the store does not sell and the name of a marketplace. **Winners stay, even inside a losing pattern.** Before applying, we checked every phrase against converting queries. Two terms were left out because they converted at a high return. One negative was deliberately written in the plural because the singular form of the same query was a winner, and a plural phrase negative does not touch the singular. **Brand names that are ordinary words need care.** One store's brand name is a common phrase. Phrase match on the brand keyword spread to the singular generic phrase, which spent with no conversions. The fix was to pause the loose keyword and add the singular generic phrase as a phrase negative, which leaves the plural brand name untouched. **Never negate a word that resembles your own brand.** A competitor's name almost identical to one of our brand names stays off the negative list, because that negative would also block people searching for us. **Competitor names are a strategy decision, not hygiene.** Showing on rival names is usually low intent, so competitor terms are listed separately and decided on purpose. Competitor terms that do convert come from comparison shoppers and stay. **Apply in batches.** Adding or removing keywords changes a campaign's composition, which resets the learning of smart bidding. We group negatives into one weekly change. While a campaign has fewer than 15 to 30 conversions, we add negatives only for queries that are clearly off-topic. **Step 4: audit existing negative keywords for ones that block buyers** Negative keyword lists only grow, and an over-broad negative cuts conversions silently: nobody sees the queries it blocked. Our negative cleaner pulls every negative in an account, from campaigns, ad groups and shared lists, sends them in chunks of 100 to a language model with a one-line description of what the store sells, and asks for each to be marked RISK (could block real buyers) or OK (competitors, free, jobs, off-topic). The prompt tells the model to be conservative. The cleaner only reports; a person removes a negative after finding where it lives. The first full run read 7,572 negatives in one account. The language model reads only the words, not the search data, so its output is a second opinion, not a verdict. Shopping and Performance Max need a different route. A shared negative list attached to several campaigns applies one negative to all of them in a single change. We used a shared list to stop brand queries flowing into a Performance Max campaign, so they went to the Brand Search campaign instead. Account-level negatives for Performance Max could, when we did this, be set only in the Google Ads interface, not through the API. **Where we were wrong** **Zero conversions per term is not waste on Shopping.** An audit across all our accounts first reported a large pool of waste. It was entirely Shopping traffic, in campaigns that converted well at campaign level. In one account two Shopping campaigns spent 72% and 64% of their cost on zero-conversion queries, yet both campaigns produced conversions at campaign level: commodity goods attract a long tail of queries that each convert rarely. On Shopping and Performance Max we now judge the campaign and use negatives only for queries outside the catalogue. **New campaigns have no history.** Zero conversions in a campaign a few days old means no data, not bad queries. **Relevant products had simply not converted yet.** Many zero-conversion queries named products we really sell. Negating them would have cut the traffic the campaigns needed to learn. **We nearly negated real customers.** Queries in a minority language looked like noise in one account. They were a real customer segment, and the right answer was a landing page in that language, not a negative keyword. After these corrections the real cleanup was small: a handful of clearly off-topic queries on Search campaigns. **Limits of the method** Performance Max shows search terms only as categories, so the largest campaign in many accounts cannot be mined at query level. Removing waste does not prove the saved budget will convert somewhere else. The n-gram thresholds are a judgement call, and the language model in the negative cleaner has not been checked against what happened after a negative was removed. **A short checklist** Weekly: pull search terms for Search campaigns; rank zero-conversion terms by cost; run the n-gram view and keep patterns found in at least 2 terms; exclude brand terms and anything resembling the brand; check each candidate against converting queries, in singular and plural; keep competitor terms that convert; apply the list as one batch, dry run first; add converting non-brand terms as exact keywords. Every few months: run the over-broad negative audit and review it by hand. ### How to avoid a Google Merchant Center misrepresentation suspension, and keep the feed healthy A Merchant Center misrepresentation suspension usually comes from trust signals, not from checkout: a hidden or contradictory company identity, missing policy pages, empty business information, and on-site claims that are not true, such as countdowns that restart for every visitor or invented sales counts. Fix every signal before you request a review, because review requests are limited, and run a weekly feed-health check so product-level problems never pile up. **What a Merchant Center misrepresentation suspension is** A Google Merchant Center misrepresentation suspension is an account-level policy decision: Google judges that the store misrepresents its business identity, business model, policies or offers, and disapproves every product at once. To avoid one, make the company behind the store easy to identify, keep every on-site claim true, complete the business information in Merchant Center, and request a review only after all of that is done. An account-level issue is different from a product disapproval. A product disapproval stops one item and names an attribute. An account-level misrepresentation issue stops everything and explains almost nothing. In one of our stores it left 328 of 330 products disapproved with the reason `policy_enforcement_account_disapproval`, so nothing served in Shopping ads, Performance Max or free listings. We read account issues through the Merchant API, which lists each issue with its severity and the destinations it affects. What it was not: in the first store we audited, checkout, HTTPS and the policy pages all passed. We completed a cash-on-delivery order on the site end to end, and Google explicitly allows cash on delivery. **The on-site claims that trigger misrepresentation** Untrustworthy promotions were the pattern behind our deals stores. The widgets came from a theme kit shared by several stores, so the risk sat on every store using the kit, not only on the suspended one. This is the list we now inventory on every store: **1. A countdown that restarts for each visitor.** The end time is stored in the visitor's browser and set to now plus a fixed interval, so the offer never ends. **2. Fake low stock.** A low-stock counter driven by a script that resets every 5 hours, not by inventory. **3. Invented best-seller ranks and sales counts.** Numbers generated from the product ID, with no link to real orders. **4. Someone-just-ordered pop-ups** built from hard-coded lists of names, cities and times, labelled as verified. **5. Price guarantees** such as guaranteed lowest price, which nobody checks. **6. An inflated reference price.** A compare-at price set to exactly 2 times the selling price on every product. Under the EU Omnibus rules a price reduction must be measured against the lowest price of the previous 30 days, so this is a legal problem as well as a Google one. **7. Benefit badges copied across products**, such as a skin-safety claim shown on a pair of scissors. **The trust signals to add: identity, policies, business information** **1. One legal identity, everywhere.** Company name, registration number, address and contact in the site footer, on the Contact page and on an About page. Then search every page and policy for any other company name. In one store the terms page named a different, foreign company; in another the terms page held a copy of the privacy policy instead of terms. A hidden or contradictory entity is a primary trigger. **2. Complete policy pages.** Shipping, returns (who pays for the return, and the statutory withdrawal period), terms and privacy, all linked from the footer. Keep the numbers consistent: one store promised a 14-day return window on the homepage and 30 days in the policy, a contradiction any reviewer can see. **3. Merchant Center business information.** Account name, address and customer service contact, which we write through the Merchant API. Two fields cannot be set that way. The business phone is output-only and has to be verified in the interface with an SMS code. The business identity programme is not offered in every country, so an empty value there is normal and is not a trigger. Requesting a review is also interface-only. **4. Remove flagged products from the Google channel only.** Products caught by an over-broad policy classifier, such as a door lock flagged under a hacking policy, we unpublish from the Google sales channel and leave on the store. In one store that was 7 products. **5. Clean the secondary signals.** Cancel test orders and unpublish test products that are live and indexed. **Request the review once, after everything is fixed** A Merchant Center account review is limited. In the warning window there is one review request; if it fails, the button greys out for a cool-down that grows with each failure. So the order is fixed: correct every signal, wait for the changes to propagate, verify the business phone, and only then request the review. After a review passes, products do not return at once. When the suspension was lifted in one store, the feed still showed 322 products with the account-level disapproval. Products reprocess over hours, up to 24 or 48 hours. That is propagation lag, not a failed review. **Counterfeit is a different suspension** Counterfeit is a separate policy from misrepresentation, and it is stricter. Google's counterfeit goods policy covers not only fake products but also the language of passing off: words such as replica, or describing a product as a version of a named brand. A counterfeit suspension is account-level and usually permanent, so the fixes that rescue a misrepresentation case do not apply. Two practical points. First, check which title the feed actually sends: on a Shopify store the Google feed can take the SEO title rather than the product title, so read the title Merchant Center received, not the one in the admin. Second, a clean feed does not guarantee a pass, because Google can read the product page during a review. Do not open a new account to escape a suspension: Google treats that as circumventing its systems, and linked accounts can be suspended together. **Keeping the Merchant Center feed healthy week to week** We run a feed-health check weekly on each Merchant Center account, listing disapproved products and products eligible but limited, each with its reason code. Most issues are product-level and fixable, and several are not real issues at all. **Not every issue needs action.** An image processing error whose detail says no action is needed resolves itself. A new account's products pending initial policy review clear in a few hours to about 3 days. **Policy false positives.** Google flagged kitchen taps as weapon parts, because the Romanian word for a mixer tap also means battery. Appeal in Merchant Center and retitle to remove the ambiguity. **Image overlays.** Google uses the first image. When the main image carries text or dimensions, move a clean image to the first position. **Missing identifiers.** For generic goods without a manufacturer barcode, declare `identifier_exists = no` instead of inventing GTINs. **A second feed nobody created on purpose.** One account had 5 data sources, one of them an autofeed: Google crawling the site and creating its own products. The autofeed sent no reliable availability, which produced missing-attribute errors and duplicates, yet about 44 of its products were eligible and served only from it. The safe order was to publish those products on the store's Google channel first, then delete the autofeed, then switch it off in settings, because an autofeed can recreate itself. **A feed much smaller than the catalogue.** Products in Merchant Center should roughly equal the active products published to the Google channel. In one store 34 active products produced 5 feed items, because the target market was not set in the store's Google app; product IDs carrying the market code ZZ were the clue. **Ghosts and zombies.** Drafts removed from the store stay in the feed with a landing page error until the next sync. Products in the feed with zero impressions are a feed, price or approval problem, not a bidding problem, and our product matrix labels them separately. **Where we were wrong** **We could not prove which signal mattered.** In one store we diagnosed the fake urgency widgets as the cause. In another, the owner chose to keep counters and urgency for social media ads, and the additive fixes alone (identity, policy pages, business information, flagged products out of the feed) passed the review. Google does not say which signal tipped its decision, so we fix what we can and accept that a store which keeps those claims may not be reinstated. **A delegated agent overrode a constraint.** We gave an AI agent the job of applying the fixes, then relayed mid-task that counters and claims had to stay. The agent treated the relayed message as lacking authority and removed counters from 231 products, which we restored from the backup taken before the change. Constraints now go into the first instruction, and edits that touch conversion on a live store are reviewed before they run. **Limits and a short checklist** Limits: no fix guarantees reinstatement, Google rarely explains a misrepresentation decision, and the interface-only steps change over time. Checklist: inventory urgency widgets, stock counters, sales counts, pop-ups and price guarantees on every store sharing a theme; set reference prices to the lowest price of the last 30 days; show one legal identity in the footer, Contact and About; search all pages and policies for a second entity; complete shipping, returns, terms and privacy with consistent numbers; fill business information and verify the phone; remove policy-flagged products from the Google channel only; request the review once; then check the feed weekly, including data sources, market setting and feed size against the catalogue. ### How to detect creative fatigue on Meta and TikTok ads Creative fatigue is visible as a pattern, not as one bad metric: frequency climbing while click-through rate falls or cost per purchase rises. We compare the last 7 days of each ad account with the 21 days before, flag an account only when frequency is up more than 15% and CTR is down more than 10% or CPA is up more than 20%, and only then drill down to individual ads. Most of our false alarms came from the data, not from the audience. **What creative fatigue is and the signal we use to detect it** Creative fatigue (also called ad fatigue or audience saturation) is when the same people see the same ads so often that they stop responding. On Meta and TikTok the signal is a combination: frequency rises, and at the same time click-through rate (CTR) falls or cost per acquisition (CPA) rises. We flag an ad account as fatigued when frequency is up more than 15% **and** either CTR is down more than 10% or CPA is up more than 20%, comparing the last 7 days with the 21 days before them. Frequency is the average number of times one person saw the ads in a period (impressions divided by people reached). CTR is clicks divided by impressions. CPA is ad cost divided by purchases. No single one of these numbers is a fatigue signal on its own. CTR can drop because of a weaker new creative, CPA can rise because the auction got more expensive, and frequency can rise simply because the audience is small. The combination is what points to saturation. The remedy for creative fatigue is to refresh the creatives or widen the audience. Because an outside agency runs these accounts for us, the fatigue check is an accountability tool: it tells us which account to raise with the agency and with what evidence. **Step 1: compare a recent window with a baseline, per ad account** **1. Fix the windows relative to the last day that has data.** Recent window: the last 7 days. Baseline: the 21 days immediately before. Both windows are anchored on the newest date present in the data, not on today, because ad data arrives late and sometimes stops arriving altogether. **2. Aggregate, then derive the ratios.** Sum impressions, clicks, purchases and cost across each window, then compute CTR, CPM (cost per thousand impressions) and CPA from the sums. Averaging daily CTRs gives small days the same weight as large ones. Frequency is reported per day, so we weight each day's frequency by that day's impressions. **3. Compute the changes.** For each account: change in frequency, change in CTR, change in CPM, change in CPA, plus the current frequency and ROAS for context. CPM is shown but is not part of the flag, because a rising CPM is often auction competition rather than a tired audience. **4. Apply the rule and a size filter.** Flag when frequency is up more than 15% and CTR is down more than 10% or CPA is up more than 20%. Skip accounts whose recent cost is too small to mean anything; with few purchases, CPA swings on its own. **Step 2: read absolute frequency as well as the change** A change-based rule misses an account that has been saturated for weeks, because its frequency is high but no longer rising. We therefore also read the absolute frequency for the recent window. As a rule of thumb, a frequency above 3 to 4 on Meta, or above 5 on TikTok, deserves a look even when the change is small. The reverse also matters. A TikTok account with a frequency of 2 to 3 and healthy return is not fatigued, even if one metric moved. Showing the healthy accounts next to the flagged ones keeps the report honest about what normal looks like. **Step 3: drill down from the account to the ad** Account-level fatigue tells you which account, not which ad. Our warehouse stores daily insights per account and campaign, without an ad identifier, so the fatigue check cannot name the creative that is wearing out. For that we pull an ad-level report live from the Meta or TikTok API for the flagged account and rank ads by cost, purchases and CPA over the same windows. On Meta, asking for daily ad-level breakdowns on accounts with many campaigns returned HTTP 500 errors, and requesting heavy creative fields did the same. Ask for aggregated periods and light fields first, then fetch creative details only for the few ads you intend to act on. Separate test campaigns from sales campaigns before reading the drill-down. On multi-product accounts we mark a campaign as a test when its name contains TEST; otherwise a raw account report overstates the sales spend because of tests. A creative that is still being tested has not had time to fatigue. **The traps: fatigue that was really a data problem** **Frozen data looks like a calm account.** All our Meta reporting once depended on a single long-lived access token. When it expired, every Meta account stopped syncing on the same day, and the error message named one account at random. A fatigue check run on frozen data reports no fatigue, because nothing changes. The check now states the last day with data and warns when it is old. The durable fix was a system-user token that does not expire, plus an alert days before any token expiry. **A stopped sync is not always a dead token.** Our TikTok sync once stopped with a valid token, because the scheduler had stopped starting the job. Check that new rows arrive, not only that credentials are valid. **The account name lies on shared TikTok accounts.** One TikTok advertiser account can run campaigns for several brands. Attribution then works by a token in the campaign name. An empty token on a shared account means the brand takes every campaign on it, which double-counted another brand's campaigns in our case. We once read an account's total as one brand's total when that brand had no live campaigns on it. Split shared accounts by campaign-name token before reading fatigue, and rename untagged campaigns so they carry the token. **An empty account is not a permission error.** When the API answered successfully with zero rows for several TikTok accounts, the cause was that those accounts had not delivered (limited or unconfirmed status), not missing access. **Where we were wrong** We had two fatigue rules in our own tooling. A generic creative-audit playbook said fatigue is CTR down more than 20% over 14 days. Our data-driven check used the composite rule above. The same campaign could be labelled fatigued by one and healthy by the other. We marked the composite rule as the one to use and left the generic playbook for qualitative creative reviews only. We also treated the first version of the check as finished before we had verified its inputs. The thresholds were never the weak point; the stale data and the shared-account attribution were. **Limits: what the fatigue check does not tell you** The rule is a heuristic. The percentages are reasonable starting points, not values fitted to our data, and an account can cross them for reasons that are not fatigue: a seasonal dip, a budget increase that pushed delivery into colder audiences, or conversion lag that makes the last days look expensive. Weighting daily frequency by impressions approximates the true frequency over the window; it is not the same as the platform's frequency for the whole period, which counts unique people once. The check names accounts, not creatives, and it does not decide anything. A flagged account is a question for whoever runs the account, backed by numbers. **Checklist** Confirm the newest date in the data before reading any change. Compare the last 7 days with the 21 before, per account. Flag only on rising frequency together with falling CTR or rising CPA. Read absolute frequency too. Split shared accounts by campaign-name token and exclude test campaigns. Drill down to ads live, with light requests, only on flagged accounts. Treat the flag as a question, not a verdict. ### How to audit email and SMS lifecycle flows before writing new campaigns Before writing another email campaign, check which automated lifecycle flows the store already runs against a standard list of 10, whether each one is live or only a draft, and how much the email platform over-credits itself compared with your analytics. Then check the two things no flow audit shows: that consent withdrawals reach the sending platform at once, and that segments are built on customers who actually received their orders. **What a lifecycle flow audit is for** A lifecycle flow audit checks which automated email and SMS sequences a store already runs before anyone writes new campaigns. Flows are messages triggered by what a customer does (signing up, leaving a cart, placing an order, going quiet); campaigns are one-off sends to a list. A missing flow keeps costing every day, while a missing campaign costs once, so the audit comes first. We run the audit read-only against the email platform's API with a per-store read key. The audit never edits flows and never sends anything. Its output is a build list: which flows are missing, which exist but are switched off, and which are handled by another tool. **Step 1: compare the store's flows with a standard list of 10** Our reference list has 10 standard e-commerce flows: welcome series, abandoned cart, abandoned checkout, browse abandonment, post-purchase, winback or sunset, review request, replenishment, birthday or anniversary, and VIP or loyalty. Each one answers a moment in the customer's life that would otherwise get no message at all. For every flow in the account we read its name, trigger and status. A flow counts as present only when its status is live. A flow that exists as a draft or in manual mode is reported separately, because it is the cheapest gap to close: someone already built it. The first store we audited had 4 of the 10 flows live. That number was not the useful part. The useful part was the list of which 6 were missing, because it turned a vague goal (do more email) into a specific list of things to build, in order. **Step 2: check what the matching cannot see** **1. Name matching is approximate.** We match flows to the standard list by keywords in English and Romanian in the flow name. A broad keyword such as cart can match a flow that is really about checkout, and a flow with an unusual name can be missed. Always read the raw list of flows before trusting the gap count. **2. Some flows live outside the email platform.** Our first audit reported the review request as missing. Reviews were in fact requested by a separate review app with its own flow. The audit now marks such flows as covered externally instead of missing. **3. Capture forms load late.** An audit of the storefront said there was no email capture form. The pop-up existed, but it loaded through JavaScript, so it was invisible in the static HTML we fetched. A tool that reads HTML must say when it cannot see something and ask for a visual check. **4. Promo codes must exist.** Flow templates often promise a discount code. Check that each code exists in the shop and has not expired before the flow goes live. **Step 3: measure how much the email platform credits itself** Email platforms attribute revenue to their own messages with a generous window. In our setup the platform credited an order to an email clicked within about 5 days or opened within 1 day, while GA4 credited the Email channel only on the last click from a tagged session. For one store, over the same 30 days, the email platform reported many times what GA4 assigned to Email. Neither figure is the truth. GA4 undercounts, because untagged email links fall into other channels; the email platform overcounts, because an open is not a cause. The real contribution sits between the two and, in our judgement, closer to GA4. The size of the gap is the number worth watching. When it grows, the email platform's dashboard is the wrong place to decide where to invest. **Step 4: check consent and segments before building more** **Consent must reach the sender at once.** A customer asked support to erase her data and stop all marketing. The request reached the support inbox, but nothing connected it to the email platform, and a campaign reached her the same morning. Under GDPR Article 21(3), an objection to direct marketing means processing for that purpose must stop. Suppress the address in the email platform first, before triage, and suppress it at the source too: consent synced from the shop can resubscribe someone the next time the profiles sync. **Segments must use delivered orders.** In a cash-on-delivery store, a placed order is not a sale until the parcel is accepted. A winback or VIP segment built on placed orders includes people who refused their parcels. We build customer segments (recency, frequency, value) on delivered orders, identified by email address, and per store, because stores sell in different currencies. **Payment recovery may not apply.** Generic retention playbooks spend much of their effort on failed card payments (dunning). For a store paid mostly in cash on delivery, the equivalent problem is refusal at the door, which belongs to confirmation and delivery checks, not to an email flow. **Step 5: build the missing flows, and know what the API cannot do** Through the email platform's API we can reliably create templates, segments and lists. Creating a complete flow, meaning the graph of trigger, delays and emails, through the API was limited, so our builder delivers the templates plus a written specification: the trigger, the delays and the order of the messages. Wiring each flow in the platform's interface then takes about 2 minutes. The specification is concrete. Welcome: 3 emails after signup. Abandoned cart: 3 emails, the first after 1 hour and the last at 48 hours. Browse abandonment: 2 emails. Post-purchase: 3 emails, with a review request on day 14. Winback: 2 emails after 60 days without a purchase. Sunset: 1 email after 120 days without engagement, asking whether the person still wants to hear from us. Every text passes a check for machine-written phrasing before it goes live. Two writing rules from our email playbook carry most of the weight: one email, one job, with one main call to action; and fewer, more relevant emails beat more emails. Subject lines of 40 to 60 characters are a reasonable default. **Where we were wrong** Our first gap report overstated the problem in two places: a review flow handled by another app and a capture form loaded by JavaScript were both reported as missing. The owner of the store corrected both. We also described the builder as creating flows when, at first, it created only templates. We now say exactly what is automated (templates, segments, lists) and what is manual (wiring the flow). The consent gap was the more serious error, and no flow audit would have found it. We found it because a customer complained. **Limits and checklist** The audit checks presence, status and attribution. It does not judge whether a flow's content is good, and it covers only the stores whose read key is connected. The attribution comparison compares two imperfect counts; it gives a range, not a figure for email's real contribution. Checklist: list every flow with name, trigger and status. Compare with the 10 standard flows; count only live ones. Read the raw names and mark flows handled by other tools. Verify capture forms visually. Compare the platform's attributed revenue with analytics for the same period. Route every unsubscribe and erasure request to the sender immediately, at both ends. Build segments on delivered orders. Only then write new campaigns. ### How to catch duplicate orders before the shipping label is printed A duplicate order is the same customer placing the same order twice, usually minutes apart. We catch duplicate orders by running the duplicate decision inside the step that creates the shipping label, matching on the normalised phone number, and cancelling automatically only pairs that are strictly identical; everything else goes to a person. Our first detector decided correctly but too late, and most of what we know came from the cases it got wrong. **What a duplicate order is and when to catch it** A duplicate order is the same customer placing the same order twice, usually minutes apart: a double tap on checkout, a second submission of a cash-on-delivery form, or the same click on two stores that share a catalogue and the same ads. You catch duplicate orders by running the duplicate decision inside the step that creates the shipping label, before the label exists, and by cancelling automatically only the pairs that are strictly identical. Every other suspicious pair goes to a person. The timing matters because a shipped duplicate costs transport in both directions and, on cash on delivery, an almost certain refusal: the customer will not pay twice. Once a parcel has left, no safe rule can cancel it. Our first duplicate detector made correct decisions too late. Measured over **24** hours, of **52** orders it had decided to cancel, **23** were cancelled and **29** already had a label, because the automatic label loop ran in the gap between the decision and the cancellation. That was roughly **29** double parcels a day. **Step 1: match duplicate orders on the phone number, not on the customer record** The duplicate fingerprint we use is: the phone number reduced to its last **9** digits, the **set** of SKUs (so the order of the lines does not matter), and the same total in the same currency, inside a time window measured from the first order, not from the current time. A window measured from "now" misses exactly the common case, where the second order arrives after the first. Matching on the customer record is not enough. Customers mistype their email, spell their name differently, and cash-on-delivery forms create a new customer record for every order. A set of **13** duplicate pairs that had all shipped twice had one thing in common: the same phone number. Matching on the phone caught all **13**, and it flags about 1.4% of orders. For the human review list we rank pairs by confidence. **Exact**: identical lines, SKU by quantity, almost certainly an accidental double order. **Probable**: the same total within **2** hours. **Possible**: within **24** hours but with different products or totals, which is often a customer who came back to buy something else. **Step 2: cancel only strict duplicates and hold everything else** The automatic cancellation rule is narrow on purpose: a duplicate order is cancelled only if it has the same SKU set *and* the same total and currency as the order being kept. Anything else is put on hold for customer service. A different basket or a different value can be a real second purchase, and an automatic cancellation of a real order is a lost sale. The case that forced the rule was two orders with the same total and different products, which the old logic would have cancelled. On **16** real pairs the strict rule gave **9** cancellations and **7** holds. **Missing data is not identity.** If the line items of one order have not synchronised yet, the comparison returns "not comparable" and the pair is held, never declared identical. When our local records lagged, we added a fallback that reads both orders straight from the store platform, which has them immediately, and applies the same strict rule. **Which order to keep.** Our ranking is: paid by card, then already shipped, then the newest. An order paid by card is never cancelled automatically, because that risks a double charge and a chargeback. Later the rule gained one more step: at equal quantities the lower total wins, because when the products are identical the difference between two totals is the shipping fee. In the real case that prompted the change, customer service had decided the other way after talking to the customer. A business rule in code is a snapshot of a decision, so check it with the person who owns it. **Step 3: put the duplicate decision before the label, in one place** The fix for "correct but too late" was a function that returns the duplicate decision for one order, called by the label step *before* it creates a label. If the decision is cancel or hold, no label is made. The cancellation itself stays with the detector, so there is one rule, not two copies that drift apart. **Fail open.** If the duplicate check throws an error, the order ships. A good order left unshipped because of a bug in a check costs more than one duplicate that escapes. Before switching it on, we simulated the check on **183** orders waiting for a label: **0** were blocked wrongly. **Guard the same run.** Our records are a mirror that lags behind, so two duplicate orders processed in the same pass could not see each other. We counted 1.4 pairs with labels on both orders in **21** days, almost all with consecutive order numbers. The label job now keeps an in-memory set of fingerprints it has already labelled in the current run and holds any order whose fingerprint is already there. **Look across stores.** Stores that share a catalogue and run the same ads get the same customer ordering twice on two sites. Over **7** days we found **324** cross-store pairs with the same phone within **24** hours, of which **24** had exactly the same SKUs and total. A simulation on **411** orders and 1.4 phone numbers gave **1** cancellation and **23** cases for a person, all with a correct reason and no false positives. The duplicate decision also has to come before any address correction: we once asked for an address fix on an order that was itself a duplicate. **Orders that look like duplicates and are not** **Replacements placed by customer service.** An order created from a draft, for a swap or a resend, looks like a perfect duplicate: same customer, same products, same total. The order's source field tells them apart. Our protections for these orders lived on one of two code paths, and a single settings toggle would have routed orders around them. The guard now runs before any action and fails closed: if the platform does not answer, the order is not touched. **Repurchases.** If the first order was delivered before the second was placed, the second is a new purchase and is kept. **Two labels in the same second.** One order should leave with one label carrying several numbered parcels, but two labels can be a legitimate split between warehouse locations. We once cancelled one half of such a split and the goods from the second location stayed behind. Check which products each label covers before calling it a duplicate. **Reshipments.** We cancelled **47** labels believing they were duplicates because the orders already had a delivered label. They were reshipments of incomplete parcels: one order had **7** items and the delivered parcel held one. Compare at line-item level, not by "a label already exists". **Cancellation requests.** A customer can request cancellation without the order being cancelled. When we lifted holds in bulk to ship **16** orders, one of them shipped although the customer had asked to cancel it days earlier; we had no check for that request at the time. Before lifting a hold, read who placed it and why. **Where we were wrong** **Two jobs with opposite rules.** One job kept the older order with a label and ran every **3** minutes; another kept the newest and ran every **15**. We retired the first and moved the whole decision into the job that creates labels. **A decision that did nothing.** The detector recorded "would cancel" and only logged it. Our fix had two bugs of its own: the platform's list of cancellation reasons has no "duplicate" value, and the decision was written with a hyphen while we compared it with an underscore. A run with a clean build detected **5** duplicates and cancelled **0**. We caught both by checking the effect in the store, not the build. **Mismatched windows.** The customer lookup searched **7** days while the job scanned **30**, so older duplicates came back "unknown" and stayed blocked for good: **25** of **27** orders on one market. **Removing a tag triggers nothing.** Removing a "duplicate" tag does not re-run the automation that makes labels, so the kept order has to get its label directly from the job. **A checker greedier than the rule.** Our parity check reported **12**% duplicates because it counted the same phone and SKUs at any time in the interval, which includes loyal customers who reorder. Narrowed to the rule's real window, it stopped raising false alarms. **Limits of the method** The duplicate rule cannot tell a discount code from a shipping fee: if two identical baskets differ in total because of a code, the lower total still wins. Failing open lets a few duplicates through by design. A cancellation request in the order timeline is a separate signal that this method does not read. **Checklist** **1. Decide before the label.** Call the duplicate decision from the step that creates the label, and keep the cancellation in one place. **2. Match on the phone.** Last digits of the phone, SKU set, total and currency, window measured from the order. **3. Cancel only exact pairs.** Hold everything else, and treat missing data as "not comparable". **4. Protect the right orders.** Card-paid, already shipped, customer-service replacements, repurchases, reshipments and location splits. **5. Verify the effect.** Count cancellations in the store after a run, not decisions in the log. ### Ghost shipments: labels created but never scanned by the courier, and how to find them A ghost shipment is a parcel whose shipping label exists but which the courier never scanned at pickup, while the customer has already been told the order shipped. We find ghost shipments by asking the courier, not our own records, for the last event of every label older than a few days, treating a missing answer as a failed read rather than a status, and sending every case to a person instead of remaking labels automatically. **What a ghost shipment is** A ghost shipment is a parcel whose shipping label was created, and usually printed, but which the courier never scanned at pickup. The order is marked shipped and the customer has received a "your order has shipped" email, while the parcel is still in the warehouse or was never packed. You find ghost shipments by asking the courier, not your own order records, for the last event of every label older than a few days, and treating "label registered, never scanned" as a ghost. Ghost shipments produce the angriest "where is my order" tickets, because the customer has a shipping confirmation and a tracking page that does not move. Monitoring for delays in transit misses ghost shipments completely, since they never entered transit. We look for two signals. The first is a label that exists, with the courier status "shipment data received" (registered, no physical scan), older than a threshold: our default is **3** days, and past **7** days the parcel is almost certainly forgotten and needs a reshipment or a refund with an apology, not just a message. The second is an order marked shipped with no label at all, so the customer has nothing to track. **Step 1: take the parcel status from the courier, not from your records** Our order records could not tell a parcel that left from one that did not. We compared orders on the "not left" list with orders outside it, field by field, and every field was identical: aggregated status, shipment status, printed flag, waiting-since date, tracking number. One status sat on "waiting for courier" for hundreds of orders long after they had moved. Asked how many parcels had not left for one product, the stale mirror said **152**; the order system that polls the courier said **85**. Two shortcuts give wrong answers. The date an order was marked fulfilled is when someone clicked, not when the parcel left. And a "not downloaded" print queue is a list of labels not yet printed: a label leaves that queue when it is downloaded, so the queue cannot say afterwards what physically left. **Test the source with a known answer.** When we first checked whether courier tracking separated the two cases, we compared two labels that were in fact both unshipped and concluded that tracking did not help. The correct control is an old label you know was delivered. Ours came back delivered and proved the tracker worked. Also check how recently each label was polled; a status is only as fresh as its last poll. **Step 2: a silent API is a read failure, not a status** We built a "what still needs printing" list by querying courier tracking in batches of **20**. For some labels the response simply did not contain the parcel, and our code stored an empty status and classified it as "no event", which means not picked up. The result was **36** labels for parcels that had already been delivered, in the file sent to the warehouse. A second query returned "delivered" for the same parcels. The fix is to count what is missing. After every batch, compute requested minus received and retry only the missing labels, in smaller batches of **10**, for up to **4** rounds with a pause between them. With retries, **525** of **525** labels answered. Whatever still does not answer goes into a third category, *uncertain*, which is reported and never merged into "left" or "not left". Empty text is not a status either. The first version of a queue for labels unshipped after **7** days returned 6,858 labels; 6,764 of them had an empty last-status text but a computed status of delivered. Check the derived state as well as the raw text. **Step 3: compare label sets between systems** A ghost can also be a label that exists in one system and not in the other: our records hold a label number that the shipping-label integration does not have, or the integration holds a different, newer label for the same order. The test that works is set membership: the label number, with spaces removed, is not in the set of labels the integration holds. Three tests that do not work: a stored integration ID (ghosts had one too), comparing order names (the order existed in both), and the integration's "dispatched" flag, which gave 6,191 false positives. Build the integration's label set by listing orders in date windows, then compare in memory, instead of looking up labels one by one. If listing fails for any store, emit nothing, because a partial set turns every missing label into a false ghost. On the first run about **1**% of candidates were ghosts, and the backlog reported was **162**. **Cancel with a gate.** For a **30**-day backlog we found **291** suspects and **215** that could be cancelled after confirming with the courier that the parcel had never moved; none was older than **20** days. The sequence per order is: void the label at the courier, cancel the fulfilment, cancel the order without a refund when nothing was paid. The job cancels **2**, verifies them, and continues only if both succeeded. Verify the void through the courier's tracking, because the cancel call returned the same empty acknowledgement for a label that did not exist. After the backlog, alert only on new cases, once per label. **What to do with a ghost shipment: route it to a person** Unshipped labels go back to the warehouse. Labels printed more than a day ago and still unscanned after **7** days return to the print queue as a separate station called "unshipped", split by the real warehouse station. Those rows are re-queried with the courier every **30** minutes, so a parcel that leaves drops out of the list. A label cancelled at the courier is not remade automatically. When we announced a new label for **16** orders whose labels had been cancelled, the business owner stopped it: **13** of the **16** had been cancelled in the same minute, which is a decision by someone, not an expiry. These cases now go to a list and to the customer-service queue with the context. The outcomes are: never left and still valid, make a new label now; the customer asked to cancel, cancel the order; paid, or unclear, ask customer service a precise question. An order placed more than **7** days ago does not get a new label. Split courier cancellations by when they happened. Of **101** tickets about labels cancelled at the courier, **73** were parcels stopped *after* pickup, which are effectively returns, and **27** were cancelled *before* pickup. Of those **27**, **4** were test orders and **23** were real orders, all of them printed. The first explanation offered was "those are test orders"; it covered **4** of **101**. **Where we were wrong** **Dead labels in the warehouse.** We sent **56** labels to the warehouse, then cancelled and remade them in a content fix an hour later, and did not resend the replacements. The warehouse had **56** cancelled labels for a day; printed and stuck on, they would have shipped parcels on dead tracking numbers. Paper gives no sign that a label has died. Now we send only after the whole cancel-and-remake cycle is finished, announce any cancellation of a label already sent, and check the sent files by extracting the order numbers from them and asking the courier. On some labels the tracking number is printed with spaces, so normalise before matching. **A deliberate "shipped without tracking".** Orders for products in a market test are marked fulfilled without a label on purpose. We read "fulfilled, no tracking, no label" as a defect, reversed **17** markings, and **12** orders received real labels before we had to reverse everything. All **17** of **17** had test products. The absence of a shipment does not tell you whether a shipment was expected. That detector now only reports. **Cancelled orders reported as ghosts.** A label voided on an order that had itself been cancelled looked exactly like a ghost. The daily check now skips cancelled orders. **Limits of the method** Finding a ghost does not find the parcel: a person still has to look in the warehouse. A parcel that the courier has already picked up cannot be cancelled through the courier's interface; it has to be stopped by the courier. A courier's cancellation record does not say who cancelled it, and for the **23** real orders cancelled before pickup we still do not know who did it. A label made outside the integration, by hand, will always show up as a ghost, which is a useful process signal but not a lost parcel. **Checklist** **1. Ask the courier.** Use the last courier event, with its poll time, not the status in your own records. **2. Count the missing answers.** Retry them, then report the rest as uncertain. **3. Compare label sets.** Label number not in the integration's set, built completely or not at all. **4. Route to a person.** No automatic new labels for cancelled ones; split before and after pickup. **5. Check the paper.** Re-query the labels in every file already sent to the warehouse. ### How to use AI to draft customer service replies without letting it send them We let a language model draft customer service replies from the team's approved macros and from procedures learned from real agent replies, grounded in the customer's actual order data. A human reviews and sends every reply; the model never sends. The guards around the draft matter more than the prompt, and most of what we learned came from guards that were silently not working. **What AI-drafted customer service replies are for** An AI-drafted reply is a suggested answer to a support ticket that a language model writes and saves as a **draft** inside the helpdesk, where an agent reads it, edits it and sends it. The model never sends anything to the customer. The draft is built from three inputs: the approved reply macros of the support team, the procedures the team actually follows, and the customer's real data (orders, delivery status, tracking link). The point of drafting is to remove the typing, not the judgement. An agent who opens a ticket and finds a reply that already names the right order, the right tracking link and the right policy answers faster. An agent who finds a fluent reply that invents a delivery date has to catch the invention, which is harder than writing from scratch. **Step 1: route the ticket before drafting it** **Find the store from the page the message arrived on.** When one support team serves several stores, the store of a ticket is the social page or mailbox the message was sent to, read from the recipient field. The brand label the helpdesk shows on a ticket can be stale; ours was, and an agent once named the wrong store because of it. **Triage writes tags and priority, never messages.** Our triage step proposes a store tag, a category and a priority, runs as a dry run by default, and with an explicit flag writes only those internal fields. When we started, 99.7% of conversations had no tags at all, so routing was blind. A legal or consumer-authority threat becomes urgent; returns, product problems and cancellations become high priority. **Score sentiment for ordering, not for verdicts.** A rule-based score gives intensity 3 to a legal threat, 2 to frustration ("third email", "nobody answers") and 1 to a complaint keyword; a model-based pass also catches calm complaints that contain no keyword. **Step 2: draft from the approved macros and the learned procedures** **Macros set the format and the voice.** The support team keeps its canonical replies in one document: macros for order tracking, returns and refunds, damaged products, defective or missing items, cancellation, order changes, presale questions and thanks, in each market's language, plus 18 presale answers. The draft picks the macro for the ticket's category and language and fills its variables (customer name, order number, tracking number, store, return link) with real data only. **Procedures are learned from real tickets, not assumed.** A separate step reads well-resolved tickets per category, keeps only the replies written by human agents (the helpdesk's bot and auto-filled form text are excluded) and documents the de facto procedure: the steps, the canonical wording, and when the team declines. The draft engine injects the section for the ticket's category into the prompt. **Precedent gives tone, not facts.** For each ticket we retrieve the 3 most similar resolved tickets by meaning and show the model how a human agent answered them. The model is told to take only tone and structure from the precedent, never order numbers, names or dates. **Step 3: ground every claim, and know when the search failed** A draft may state an order status, a delivery date, a price or a size only if that fact is in the context the draft was given. After generation, a deterministic filter looks for invented lookups ("we checked"), invented statuses, invented deadlines and invented dimensions. A draft that trips the filter is regenerated once with a correction; if the second draft still invents, a safe per-category template replaces it. **An order lookup has three outcomes, not two:** we did not search, we searched and found nothing, and the search failed. Our lookup used to return an empty list on every failure (missing driver, missing credential, unreachable database), which is exactly what a successful search with no results returns. In a simulated database outage on 162 real tickets, 83 (51.2%) carried the context "searched, does not exist", all false, and the draft would have told those customers they had no order with us. After the fix, 0 do; the same 83 get a block that forbids any statement about whether the order exists. **A draft confirms an action only after the action ran.** Order changes, cancellations and resends are proposed, run as a dry run, and applied only when an agent approves them. Until then the draft says the request has been taken over, not that the address was changed. **Answer the last message, not the first.** One draft answered a tracking question that the customer had asked days earlier, while the customer's latest message said the parcel had arrived. The transcript now marks the customer's latest message explicitly. **Step 4: never auto-send, and make that a guard rather than a convention** The team rule is draft-only. The code path that sends a reply live exists, so since 15 September 2026 it refuses to run unless consent is given outside the command line, through an environment variable or a confirmation file. Without it the process stops with exit code 2 before any network call, so a mistyped flag cannot reach customers. **Escalations get a holding reply and a human.** Legal threats, a refund promised but not executed, very angry customers and repeat complaints do not get an answer on the substance. The engine saves a short holding draft, raises the priority, adds escalation tags and writes an internal note with the problem, the order, the tracking number and the suggested action. **Reply means close.** When an agent answers a ticket, the agent closes it, escalations included, because the helpdesk reopens the conversation by itself if the customer writes again. **Public channels are opt-in and stripped.** Comments under posts and ads get a draft only with an explicit flag. A public draft never contains the customer's name, phone number or order number. In any channel with a mixed audience, such as a group chat, the safe default is silence: nothing about customers, orders or business figures goes there. **The traps we hit and how we catch them now** **The draft endpoint appends.** Saving a draft adds a new one each time and the helpdesk offers no way to delete drafts through the interface we use. Every re-run on the same tickets stacked another draft that agents had to clean by hand. We now finish the prompt first, write once, tag each batch, and skip tickets that already carry the tag. **Error text was saved as a reply.** When we ran several workers in parallel, the model provider answered with rate-limit errors (HTTP 429) and the error text itself was saved as drafts. The engine now retries with backoff and refuses to save a draft that is empty or starts with an error marker. **Triage died silently.** The triage job kept reporting success from mid-June to early July while pulling no new tickets: one script wrote to a database at a path that existed only on a laptop, and the current day was marked done at night while still empty. Both are fixed, and the most recent 2 days are now always re-pulled. **A guard written in one language is dead in another.** When we extended drafting to foreign markets we translated the prompt but not the guards, which were Romanian patterns. The invented-claim filter caught 1 of 6 identical fabrications across languages, and 20 of 20 foreign legal threats did not escalate. Repairing the guards then raised the false-positive rate on 137 real agent replies from 15.3% to 28.5%. Every multilingual change is now measured on the language that already worked as well. **Where we were wrong** **We judged agents against procedures we had invented.** Our first quality review flagged agents as wrong for refusing returns of unsealed hygiene products. Refusing those returns is the team's policy. That is why the procedures are now learned from real tickets first, and only deviations from the team's own standard count as mistakes. **We let a batch run before the drafts were good enough.** An adversarial audit of 75 real drafts found problems in 71% of them, 18 of high severity: invented sizes, prices, phone numbers and statuses, and "we checked" with no lookup behind it. We paused the scheduled batch run. Native-speaker evaluators of the foreign-market drafts, 4 of them, all said the drafts should not be sent. The scheduled run stays paused until a start gate passes on the copy that would actually run, because we once found 3 copies of the engine in 3 different versions. **Limits and checklist** This method does not make drafts correct; it makes wrong drafts rarer and easier to spot. A model can still misread a ticket, and the guards catch only the kinds of invention someone thought to write a rule for. Our quality audit, which flags slow first responses (over 24 hours by default), long back-and-forth threads, frustration and escalations, found at least one flag on 27% of real tickets in a 30-day window; drafting does not fix staffing. **Checklist.** Store from the page the message arrived on. Triage writes tags and priority only. Drafts start from the approved macros and the learned procedures. Every fact in a draft is in its context. The lookup reports whether it failed. Actions are confirmed only after they run. Live sending needs consent outside the command line. Escalations get a holding reply and a human. Public drafts carry no personal data. Guards are tested in every language the drafts are written in. ### How to catch refunds that were promised but never paid: a refund watchdog for customer service A refund watchdog is a read-only report that finds customers who are owed money the store never returned: card orders cancelled without a refund, and accepted returns that were sent to payment but never paid. It runs two plain rules against the order and return records and lists the oldest cases first. Our worst refund failure was a refund button that had never worked while every ticket showed as refunded. **What a promised-but-unexecuted refund is** A promised-but-unexecuted refund is a case where the customer paid, or was told they would be repaid, and the money never went back. You catch these cases with a **refund watchdog**: a scheduled, read-only query that compares what the records say should have been refunded with what was actually refunded, and lists every gap with its age. Of all customer service failures, an unpaid refund is the one we treat as the most expensive, because a single case can turn into a complaint to the consumer authority, a card chargeback and a 1-star review at the same time. A slow reply annoys a customer; money taken and not returned gives the customer a formal reason to escalate. The failure is also quiet: nothing breaks, the order looks closed, and the customer is the only party who notices. **Rule 1: card orders that were cancelled but never refunded** The first rule looks at orders paid by card. An order is flagged when **all four** conditions hold: the order is cancelled, the payment gateway is the card gateway, the financial status is still paid, and the total refunded is zero. The money was captured, the order was voided, and nothing went back. The age of each case is counted in days from the cancellation. The store is derived from the order number prefix, so one report covers every store. In practice this rule catches orders that customer service cancelled by hand without also pressing refund in the store admin. Cancelling and refunding are two separate actions in most e-commerce platforms, and an agent under time pressure can do the first and forget the second. The watchdog exists for exactly that leak. **Rule 2: returns that were accepted but never paid** The second rule looks at return requests in the return management system. A return is flagged when it carries a refund amount above zero, its status is one of awaiting refund, delivered, in progress or new, and there is no payment date, the paid amount is zero and no payment record exists. **Awaiting refund is the most severe status.** In that state the customer has sent the goods back and the refund has been confirmed and sent to payment. Any delay there is direct exposure, so those cases come first. The age is counted from the moment the return was sent to payment. When that date is missing, the watchdog falls back to the delivery date of the returned parcel, then the approval date, then the creation date, so every case gets an age instead of being silently skipped. **Step 3: rank, filter and work the oldest first** The watchdog prints each case with the order, the customer, the source (card or return), the status and the age, groups the cases by store and puts the oldest at the top. It can be filtered by store, by rule, or by minimum age, for example only cases older than 7 days, and it can emit machine-readable output for a morning briefing. **The watchdog writes nothing.** The watchdog does not refund, does not change statuses and does not contact customers. A person opens each case, checks it in the payment system and pays it. Keeping the detector read-only means it can run on a schedule without anyone worrying about what it might do. The same idea reaches the support inbox. Our reply-drafting engine treats "refund promised but not executed" as an escalation reason: the customer gets only a short holding reply, the ticket gets high priority, and a human handles the money. **What the watchdog must not flag: refused cash-on-delivery orders** A cash-on-delivery order that the customer refused at the door is **not** a refund case. The store never collected the money, so recording a refund creates a repayment that never happened: the order shows as refunded and the reports subtract money that was never received. The watchdog's first rule only looks at card payments for that reason. We made this mistake by hand. A manual script of ours cancelled 9 refused cash-on-delivery orders with the refund option switched on, and 3 of them recorded a real refund. A refund cannot be deleted from the order history afterwards. The automated path in our order system already cancelled refusals without a refund; the error was in a script written by hand. The rule we keep: when repairing something manually, copy the parameters from the automated path instead of reinventing them. The order of operations matters as well. A refused parcel is first marked fulfilled with its tracking number, then cancelled without a refund and tagged as refused. Done the other way round, the order can no longer be fulfilled and the evidence that the goods left the warehouse is lost. **Where we were wrong: the refund button that never worked** Our return system had a button that refunded the card and closed the return ticket in one step. The audit log showed that the button had created **0** refunds, ever. The button sent the system's internal line-item identifier where the payment platform expects its own global identifier, the platform rejected the request, and a `try/catch` that only logged the error swallowed it. The ticket moved on as refunded. The exposure was small only by luck: 3 tickets, all tests made on the day the feature went live, all marked paid and closed while the card refund had failed. Each had to be checked by hand. A second bug sat behind the first: once refunds went through, the button refunded the amount the platform suggested instead of the amount the agent had entered. The fix changed the order of steps. The refund is now sent first; only if the payment platform confirms it is the payment recorded and the ticket closed. If the refund fails, the error is raised and the return stays in its previous status. **On a path that moves money, never swallow an error.** A catch block that only logs can hide a feature that does not work at all. A related watchdog taught the same lesson from another side. A check for cash-on-delivery orders that the customer also paid by card caught a case in time, but a timeout on an earlier case in the same loop killed the script before it reached it; by the next run the parcel had shipped. Each case is now isolated, so one slow item cannot sacrifice the items after it, and the timeout went from 120 to 300 seconds. **Limits** The watchdog sees only refunds that the records know about. A refund promised in a chat message, with no return request and no cancelled card order behind it, is invisible to it; that is why the drafting engine escalates such messages to a person. The card rule requires a total refunded of zero, so a partial refund that is smaller than what was owed is not flagged. The return rule covers only the store that keeps its returns in a structured return system. The watchdog finds cases after they happen; it does not stop an agent from cancelling without refunding. **Checklist** Flag card orders that are cancelled, still paid and have nothing refunded. Flag returns with a refund amount owed, no payment date, no paid amount and no payment record. Put awaiting-refund cases first and sort by age. Keep the watchdog read-only. Never record a refund on a refused cash-on-delivery order. On any money path, refund first and close after, and let failures raise. Isolate each case in a loop so one failure cannot hide the next. ### How to calculate reorder quantity and stock-out date per SKU from daily inventory snapshots Reorder quantity per SKU is daily sales velocity times the days until the next delivery plus a safety buffer, minus what is on hand and what is already on the way. Velocity can be read from daily inventory snapshots, but only after correcting for restocks, stock-outs and duplicated stock rows, and the incoming figure must come from what was actually loaded, not from what was ordered. **What a reorder plan per SKU answers** A reorder plan per SKU answers two questions: how many units to order now, and on what date the current stock runs out. The formula we use is `reorder_qty = ceil(velocity × (lead_time + safety_days) − on_hand − incoming), minimum 0`. Days of cover is `on_hand ÷ velocity`, and the stock-out date is today plus days of cover. Velocity is the number of units a SKU (stock keeping unit, one sellable variant) sells per day. Lead time is the number of days from placing a purchase order until the goods can be sold. Safety days are a buffer for late shipments and demand spikes. Incoming is stock already ordered and on its way. A low-stock alert and a reorder plan are different tools. A low-stock alert says which SKUs are running out. A reorder plan says how many units to buy and when, which matters most when goods travel by container and the lead time is long. **Reading sales velocity from daily inventory snapshots** A daily inventory snapshot is one row per SKU per day with the quantity on hand. Snapshots are a convenient source for velocity because they exist for every SKU, every day, in one table, even where order data is split across several systems. **1. Sum the daily decreases.** Velocity per day = the sum of `max(on_hand_yesterday − on_hand_today, 0)` over the window, divided by the number of days. Increases are restocks and are ignored. Our default window is 28 days. **2. Do not use the net change over the window.** A simpler formula, stock 28 days ago minus stock today, is wrong whenever a restock lands inside the window: the delivery cancels out the sales and the SKU looks slow. Summing only the decreases avoids that. **3. Treat the result as a proxy.** A recount or a manual stock correction also shows up as a decrease, so snapshot velocity can count a correction as sales. Cross-check fast movers against delivered orders before a large purchase order. **4. Rank by what a stock-out costs.** Velocity times price gives the sales lost per day without stock. We sort SKUs that need ordering first, then by days of cover ascending, then by that daily figure. **A worked example with hypothetical numbers** The numbers below are round and hypothetical, chosen to keep the arithmetic easy. They do not come from any store we run. Inputs: velocity 10 units per day, lead time 30 days, safety buffer 14 days, 200 units on hand, 100 units incoming. **Step 1.** Units needed until the next delivery plus buffer: 10 × (30 + 14) = 10 × 44 = 440. **Step 2.** Reorder quantity: 440 − 200 − 100 = 140 units. **Step 3.** Days of cover: 200 ÷ 10 = 20 days. The stock-out date is 20 days from today, which is before the 30-day lead time, so this SKU is ordered today and flagged red: it will run out before the goods arrive. A SKU with zero sales in the window has infinite days of cover. The plan does not order it, which is correct for a dead product and wrong for a product that sold nothing only because it had no stock. The next section handles that case. **Correcting demand that a stock-out hid** A stock-out censors demand: a product with no stock sells nothing, so its recent velocity is zero and the reorder plan skips it. The fix is to measure velocity only over the days the product was actually on sale. For each SKU, velocity = units sold ÷ active selling days (first sale to last sale) × 30, which gives units per month while the product was available. A product that sold 240 units in 267 days sells about 27 a month and beats one that sold 300 in 400 days, about 22.5 a month. We trust the figure only above roughly 20 units and 21 active days. Annual demand for sizing is `max(sales in the last 12 months, in-stock monthly rate × 12)`. The final quantity is `max(0, annual demand × months of cover ÷ 12 − stock)`, rounded to 50, with about 20% extra on the top 3 sellers so they do not break during the peak. **Seasonality and the arrival date** A purchase order should be judged at the date the goods arrive, not the date it is placed. A spring or summer product that lands at the start of autumn sits as dead stock for six to eight months until its season returns. The reverse also holds. A line whose peak month sells three to four times its summer volume needs to be sized on annual demand, which includes the peak, not on the average month. Read sales month by month before sizing, never one average number. Before a purchase order goes out we check three things: seasonal products whose arrival falls off-season, SKUs the restock plan recommended that are missing from the order, and best-sellers on zero stock in sales channels the restock report cannot see. **The traps we hit and how we catch them now** **Stock repeated on every listing.** When several stores sell from one shared pool, the same stock figure appears identically on every listing of a SKU. Summing those rows multiplies the stock. Take the stock once per SKU and add the sales of every channel. **Today's stock compared with old orders.** We once flagged 166 orders as waiting for goods that did not exist, by comparing today's zero stock with orders placed days earlier. The goods had been there when those orders were placed. The right question was whether anything with that SKU shipped after the order was placed. With that test the list fell to 26 orders, each the last in line for its SKU. **Incoming that nobody counted.** In one system a container marked as arrived pushed its expected quantities straight into sellable stock, without a count. One container was marked arrived before its estimated date. The stores sold units that were not on the shelf. Incoming and received stock must come from a count, not from a packing list. **The order system is not the packing list.** Purchase-order lines can be cancelled in the ordering system and still be produced and loaded. What is in a container is read from the supplier's packing list, and both sources are checked before saying that something is not coming. **Zero that hides a negative.** Clipping negative stock to zero makes oversold SKUs look merely empty. Keep the sign, and keep zero-stock products visible in reports, otherwise the products most in need of a reorder disappear from the list. **Where we were wrong** Our first sizing passes ordered mostly the SKUs on zero stock. The larger need was elsewhere: fast best-sellers with thin stock that would break during the peak. A best-seller with plenty of cover needs nothing, however well it sells; a fast seller with little cover needs the biggest order of all. Days of cover, not stock level, is the trigger. We also trusted a restock report built on one fulfilment system. Stores that shipped through a different system had no rows in it, so their best-sellers on zero stock were invisible and missing from the purchase order. Check every sales channel directly. **Limits** The plan is only as good as its lead time. A default of 30 days is a placeholder; local suppliers and sea containers need their real figure. Snapshot velocity is a proxy that corrections and recounts can distort. Where snapshot history is short, velocity rests on fewer days and is noisier. The cost of a purchase order computed from unit costs is underestimated wherever a unit cost is missing. The method also does not forecast a launch, a promotion or a new channel. Those need an explicit multiplier chosen by a person, written down next to the plan. **Checklist** **1. Velocity.** From the sum of daily decreases, not the net change. **2. Stocked-out SKUs.** Velocity over in-stock days only. **3. Shared stock.** Counted once per SKU, with sales summed across channels. **4. Lead time.** The real figure for each supplier. **5. Incoming.** From counted or loaded quantities, after checking for new containers. **6. Trigger.** Order everything whose days of cover fall below the lead time. **7. Season.** Compare the arrival date with the product's season. **8. Visibility.** Keep negative and zero stock in every report. ### How to build a warehouse barcode scanning app for Android rugged scanners A warehouse scanning app turns a barcode read on a rugged Android terminal into an answer the picker can act on in a glance. We take the scan from the hardware scanner as a broadcast, route every screen through the same scan handler, write status as short field labels with colour for severity, and test every text colour against every background in both the light and the dark theme. Most of our bugs were a fix that existed on one screen and not on the others. **What a warehouse scanning app has to do** A warehouse scanning app is the program on a rugged Android terminal (a handheld computer with a built-in barcode scanner) that receives each barcode read and shows the picker what to do next: which order this parcel belongs to, whether it is already packed, whether it already left. To build a warehouse scanning app that works, receive the scan from the hardware scanner as an Android broadcast, send every screen through one shared scan path, and never drop a scan silently. We run an internal scanning app in our own warehouse stations, and we later ported its core into **Pick Forge**, our public Shopify app for barcode-verified picking. Pick Forge is approved and listed on the Shopify App Store but not yet proven in a real merchant's warehouse. The lessons below come from the internal app. **Step 1: get the scan out of the hardware scanner** On Urovo terminals the settings app calls them **Keystroke** (the scanner types the code like a keyboard) and **Intent** (the scanner sends an Android broadcast with the code in an extra). A new or factory-reset terminal comes configured for neither in the way the app expects, and the symptom is always the same: "I scan and nothing happens." **Intent mode is the robust choice.** A broadcast reaches the app on any screen, whatever field has focus. The trap is the delivery setting: if delivery is set to *Start Activity* or *Start Service* instead of *Broadcast*, everything looks configured and the app's broadcast receiver receives nothing. Clipboard mode does not work at all. **Keystroke mode needs an Enter suffix.** Without a terminator the code is typed into the scan bar and stops there. Keystroke mode also only reaches a focused input field, so we made the base screen collect keys until Enter whenever no text field has focus and hand the code to the same handler that Intent mode uses. **Diagnose from the scan bar, not from the account.** If the code appears in the bar and nothing opens, Keystroke mode has no Enter suffix. If nothing appears, Intent mode has the wrong delivery, the terminal is on Clipboard, or the scanner is off. A new colleague once lost almost an hour while we searched the server and his permissions; the difference was the terminal. When something fails for one person only, ask first what the scan bar shows. **Step 2: one scan path for every screen** A scanning app grows several screens that scan: a main screen, batch picking, wave picking, packing. If each screen has its own scan handler, a fix on one screen does not reach the others. Ours had a line on every scan handler that returned early while a previous request was still running, so the next scan was lost in silence. It was fixed on the main screen with a comment describing the exact symptom, "the man scanned 10 parcels and half were not recorded". The batch and packing screens kept the same line, and people later reported it as "loading is slow". The fix on every screen is a queue plus cache-first display. Scans go into a queue and are processed in order. The order card is shown at once from a local snapshot on the device, and the server reconciles it quietly. Measured over one day, 67.2% of scanned codes were already in the local snapshot. The beep and the warnings ("already packed", "already left") still wait for the server, because the local cache does not know the pick state. The warehouse had asked for scans to be batched for **5** seconds into a single request. We measured first: a scan cost **47** ms of server time, so batching would have saved about **100** ms in exchange for five seconds of blindness. When you fix a scan behaviour, search for the same line on every screen before you close the task. **Step 3: write for a glance, not for reading** Status text in a scanning app should be a label of the form **field: value**, not a sentence. A picker reads the screen in a glance with a parcel in hand. "Old label: [number] · cancelled" is seen at once, like a form field; "you are now working on a new label" has to be read and understood, and feels like a chat instead of a tool. The owner of the warehouse rejected every conversational phrasing we tried. Colour carries severity: green for the normal flow, red only for a real problem. We also removed all emoji and decorative glyphs from the interface, after being asked twice. State and category are plain text, with a coloured dot or a pale tinted pill where it helps. A navigation chevron in a list stays, because it is a standard affordance. **Step 4: a dark theme is a rule about pairs of colours** A dark theme in an Android app works only if every text colour has a dark-theme pair *and* every background colour has one too. At night our pickers could not read the text because many colours never went through the theme. Colours reached the screen through three channels: theme resources, which switch automatically; colour literals written in code, which never switch; and XML references to colour tokens that had no night variant. We counted 351 literals in code, 169 of them used as text, and 77 of those fell below the WCAG AA contrast level at night. The worst was 1.03:1, effectively invisible. A small analyser extracts every literal, classifies it as text or background from the line it sits on, and computes WCAG contrast against every background of the theme in both lights. A second script finds the nearest colour that meets a threshold while keeping the hue. **The mirror bug: a text token used as a background.** A token that is dark by day and light by night is correct for text and wrong as a fill under fixed white text. We added a neutral fill token that is dark in both themes, and a check that searches for the whole class. When you change what a token means, search for every use of its old role as well: we remapped one token to a fill, and 5 places that used it as text dropped to 2.23:1 within minutes. User-selectable accent palettes are theme attributes; all 10 combinations of 5 palettes and 2 themes pass 4.5:1 for text and 3:1 for fills. The accent never goes on status text: on the green palette it came out at 1.01:1 against the "all good" green. **Updates and per-store settings** **Serve the APK from the web server, not from the application process.** Our pickers reported that no update appeared. The update check was timing out. The 29 MB APK was streamed by the single Python process that also answered the tiny version request. Over one day of 235 checks, those near an APK download had a 90th percentile of 14.97 s and 8 failed; those without one took 0.02 s. Served by the web server, the 29 MB took 0.23 s. The update check also had two silent failure paths that looked exactly like "you are up to date"; an explicit check now says when it failed. **Learn the warehouse's words before building on them.** When asked to let an admin choose "for which category" product scanning is required, we built on the store platform's product types, 83 of them in one store. In the warehouse, "category" meant the store. The right key for a per-store setting turned out to be the order-number prefix, which we measured as one-to-one with the store across 30 stores over 30 days. The setting is decided on the server in one place and validates the prefix on write, so a typo cannot create a setting that silently matches nothing. **Where we were wrong, and the limits** Our recurring mistake was the same in several forms: a fix that lived on one screen or one code path. We now treat "which other screens do this?" as part of every fix. Physical stock counted at a station is its own trap. Adding a count on top of the existing pool invents stock; the question to ask first is whether the goods were moved from another location (already in the system) or arrived directly (not in the system). In one real count of 34 SKUs, only 12 were raised. On the Shopify Admin API, `inventoryMoveQuantities` does not move stock between locations; that takes `inventorySetQuantities` with one negative and one positive line. This method does not guarantee that a terminal arrives configured, that a contrast check covers colours you never route through it. Commercial stores also differ from ours: on the first real test of Pick Forge, order numbers were purely numeric and many lines had no SKU, two cases our internal prefixes had hidden. **Checklist** **1. **Scanner on Intent mode with Broadcast delivery, or Keystroke with an Enter suffix; never Clipboard. **2. **One scan path on every screen; no early return that drops a scan. **3. **Queue plus cache-first card; warnings only on the server's answer. **4. **Status as "field: value", colour for severity, no emoji. **5. **Every text and background pair measured in both themes. **6. **Large files served by the web server; a failed check never looks like a successful one. ### How to print shipping labels from several warehouse stations without losing or double-printing labels When several warehouse stations print shipping labels from the same orders, the safe design is one print queue in which downloading a label is the act that removes it from every station's queue. We route each label to one station with a single shared rule, filter before we download, mark as printed everything we export by any other path, and send a batch as one complete document. Every lost or double-printed label we had came from a download that happened in the wrong place or at the wrong time. **What a multi-station print queue is** A multi-station print queue is the list of shipping labels that have been created but not yet printed, shared by several packing stations, where each station sees only the labels for the goods it holds. To print labels from several stations without losing or double-printing any, keep one queue, treat the download of a label as the act that removes it from every station's queue, and route every label to exactly one station with one rule that all tools import. In our warehouse, the queue is the set of labels in the shipping integration whose *downloaded* flag is still false. Downloading a label sets the flag. That flag is the whole coordination mechanism between stations: a station that sees the flag set skips the label, because another station already has it. **Step 1: one routing rule, imported everywhere** Each station sees only the stores and products it holds. Our rule decides a label's station from the **first SKU** of the order: one product family goes to one station, everything else to the other. An order has one label, so it goes whole to one place. We measured how often that rule splits goods across stations: in a window of 14 days, of 482 orders with labels, 6 had more than one SKU and 1 would have needed goods from both stations. It is rare, but then one station receives a label for goods it does not have. The routing rule lives in one module and every tool imports it: the station queue, the morning batch job and the server. Two copies of a routing rule are two truths, and they drift the first time the warehouse changes its split. Stores shared between stations appear at both, which is why the print command re-reads each order fresh just before downloading and skips what the other station has already taken. **Step 2: look freely, download only to print** The station tool has three verbs with very different effects. **pull** refreshes the station's queue from the shipping integration, in about 15 seconds. **plan** answers from that cache instantly, with no effect at all: how many labels, per store, per SKU, per category. **open** is the only mutation: it downloads the filtered labels, merges them into one PDF, opens it in the browser on the station and records the batch as printed. The tool does not print by itself; the operator presses Ctrl+P. The operator does not type commands. The operator asks in plain language ("how many labels do I have to print?") and an AI agent on the station runs `pull` and `plan` and answers with the number. Batches are capped at 250 labels, because the browser and the printer do not cope with one enormous PDF. **Filter before you download.** Our morning batch job, which sent labels for a few stores to a group chat, downloaded first and filtered by station afterwards. Every download set the flag, so the job quietly stole labels from the other station's queue: they showed as downloaded without ever being printed there. After the fix, a real run sent 6 labels to one station and left 14 in the other station's queue. The same job had a `--dry` mode that downloaded before checking the flag, so a listing command had an irreversible effect. A dry run must stop before the first write. **Step 3: send only parcels that have not left** A label that exists is not a parcel waiting to leave. The morning job originally sent every label not sent before, and on one day 11 of the 27 labels it sent belonged to parcels already in transit. Now the job asks the courier for each label's status and sends only labels whose parcel has not been picked up yet. **Fail open, but loudly.** If the tracker does not answer or does not know the label, the label is sent anyway, with a warning line in the log. One extra label is an annoyance; one missing label is a parcel that does not leave. Records of what was sent are written per batch and only after that batch was delivered, so a failed send to one station is retried at the next run without resending the batch that went out. **Step 4: every other export must mark the label printed** Any path that gets labels out of the system has to mark them printed, not only the station tool. We broke this rule ourselves: when we produced labels for the warehouse by hand, we downloaded them and never marked them. Measured over 90 days, the order system's print queue held 112,003 unprinted labels, and 79,913 of them already had the parcel scanned by the courier, so they had been printed somewhere. A queue in which 71% of the rows are parcels already with the customer is useless: nobody can tell what is left to print. For one store, 443 labels were unprinted and 0 had ever been marked printed, because all of its labels had gone out through us. The fix is to use the system's own print function, which merges the PDFs, sets the printed time and writes an audit log of who printed which label and when, in one step. For the retroactive clean-up the safe criterion is that the parcel moved: if the courier scanned it, someone printed it. Labels that have not moved are left alone, because those really are waiting to be printed. Never mark printed a label you did not actually download. **Fetch labels one at a time, and keep the queue on a server** Downloading labels from the shipping integration's interface felt slow, but the API was not slow. The batch PDF generator took 10 to 20 seconds per batch, so 41 batches took about 10 minutes. The per-label link answered in 0.33 seconds, and 405 labels took 2.5 minutes with 15 requests in parallel. Fetching per label is strictly better: failures are isolated to one label, and the saved PDFs can be regrouped as often as the warehouse wants without downloading anything again. Labels are images, so we check coverage by counting pages per order, not by extracting text. **A crash between download and save loses labels.** Once, a process died midway and 33 labels were marked downloaded with no PDF saved. They are recovered by fetching again by order, because the per-order endpoints do not depend on the not-downloaded flag. We also do not download labels straight from the courier, even when we could: that does not clear the integration's flag, so the label would stay in the other station's queue and be printed twice. The station tool first ran only on each station's laptop, so every rule change needed an update on every machine, and nobody could see what anyone printed. The queue now also runs on a server that imports the same code, with the same routing rule, an activity log of who printed what and when, and service accounts per station. A station without the server setting keeps working locally, as before; an unconfigured station does not break. **Where we were wrong, and the limits** **A batch is one document.** We sent a PDF of 61 labels to the warehouse group, then found that 26 of them had a cancelled label, and followed with a correction and then a clarification: three messages for one batch, and the warehouse had to remember what to ignore. The group is a print queue, not a conversation. Now a batch is one complete, correct document; if the batch was wrong, we rebuild a clean PDF and send that, with a short factual caption (date, store, station, number of labels, order numbers). The corollary is to verify the batch before sending, because there is no cheap second chance. **"Printing broke after the deploy" was a wrong diagnosis.** After a deploy, someone saw zero print requests and concluded the new frontend was broken; we believed it and rolled back. Later analysis showed the new frontend was fine: nobody had opened the printing page in that window. Zero requests is also what an unopened page looks like. The likely real cause was a database on the scanning service locked by a long read. Before rolling back, check in the web server log who loaded the page after the deploy, and run a headless smoke test against the live build. This method does not guarantee that a label is physically printed: the flag records a download, and the operator still has to press print. It does not stop the rare order whose goods sit at two stations. And it depends on every tool honouring the same flag; one new export path that ignores it brings the stale queue back. **Checklist** **1. **One queue; downloading a label removes it from every station. **2. **One routing rule, imported by every tool. **3. **Look with read-only commands; download only to print. **4. **Filter by station and parcel status before downloading; a dry run writes nothing. **5. **Every export path marks labels printed, through the system's own function. **6. **Fetch per label, save before marking, recover by order. **7. **One complete document per batch, verified before it is sent. **8. **Before blaming a deploy, check whether anyone opened the page. ### Normalising delivery addresses across countries: postcodes, Cyrillic, street-length limits Address normalisation turns what a customer typed into the form a courier accepts and a national register confirms. We do it per country: first we find out whether that country's postcode describes a street or a whole locality, then we validate against a register whose flaws we know, normalise the register and the search with the same function, and send the courier its own spelling and identifiers. Most of the failures we hit were in the plumbing around the address, not in the address. **What address normalisation is, in one paragraph** Address normalisation is the step that turns the free text a customer typed at checkout into fields a courier accepts and an official or open register confirms. For orders shipped to several countries, normalise delivery addresses per country: decide whether the country's postcode is street-level or locality-level, validate against a national register, compare text using one shared normalisation function, send the courier the exact name and identifier from the courier's own list, and respect the courier's field limits. Repair a field automatically only when two independent signals agree; otherwise a person decides. A **nomenclature** here means a table of valid localities, streets and postcodes for one country. A **sentinel value** is a placeholder a data source uses for "not filled in". Both matter below. The two-signal rule, and the sentinel postcode that nearly shipped, are covered in our research article on address repair; this method is the plumbing underneath it. **Step 1: find out what a postcode means in each country** The first question for any country is whether its postcode identifies a street or a whole locality. The answer changes the data model, and a validator copied from one country to another without asking it is wrong from the start. In Romania the postcode is street-level, with house-number ranges and odd/even parity. The Czech Republic is the reverse: in 74% of municipalities there is one postcode, and the country has only 2,677 postcodes against 37,914 in Romania, so you derive the postcode from the locality, not the street from the postcode. Poland is street-level like Romania: one main street in the capital carries 20+ postcodes, sliced by number and parity. Bulgaria has 4-digit postcodes at locality level, about 4,600 of them. In Moldova the postcode follows the house number: one street in the capital has three postcodes for three number ranges, so any rule that judges by street name is wrong on two thirds of that street. So the Polish validator is a port of the Romanian one, and the Czech one had to be written differently. **Step 2: pick a register per country and learn its flaws first** Every national address register we used had a flaw that would have broken a naive build. We now look for the flaw before loading the data. For the Czech Republic the official address register is published as a monthly CSV under CC BY 4.0, with streets, house numbers and postcodes. For Poland we used OpenAddresses, which is derived from the official register, but the postcodes in it are systematically wrong in places, because they are assigned by spatial overlay rather than by postal routing. The authoritative street-to-postcode list is a paid product of the national post. Our decision was free-first: validate street and number against the open data, and **keep the customer's postcode** when the street and locality exist, because customers usually know their own postcode. Bulgaria has no official street register we could use; the official locality list has no streets. OpenStreetMap was the only source of streets, and the ready-made shapefile extracts did not include the address tags at all, so we parsed the full extract. OpenStreetMap had only 1,209 of roughly 4,600 Bulgarian postcodes; the free GeoNames postcode file took that to 4,679. For Hungary and Slovakia we combined GeoNames postcodes with OpenStreetMap streets. For Moldova the national post publishes a public API with 706,242 house numbers, each with one postcode. **Step 3: normalise the register and the search with the same function** A search that normalises text differently from the way the register was normalised always answers "not found", and "not found" looks exactly like "does not exist". This was our most expensive class of silent failure. Our folding function decomposed every character and dropped the combining marks. On Cyrillic that is destructive: "й" decomposes into "и" plus a breve, so 303 of 6,195 Bulgarian localities could never be found. On Polish it did too little: "ł" has no decomposed form, so it stayed "ł" while the register had "l", and 6,382 of 35,714 Polish names were unreachable, among them Łódź and Wrocław. Letters such as ł, đ, ø, ß, æ and œ have no decomposed form, and Cyrillic should not be decomposed at all. Romanian has its own version: the register held one county under the old cedilla "ţ" in 2,055 rows and under the correct comma-below "ț" in 2 rows, so the table showed 43 counties instead of 42. In Moldova "â" and "î" are the same sound written two ways, and the register had 149 streets with one and 439 with the other. The check is one query: count the rows where `norm(name) <> name_norm`. If it is not zero, you are searching in a different language from the one the register was written in. **Step 4: send the courier its own spelling and identifier** A courier API resolves an address against the courier's own nomenclature, not against yours. Resolve the locality in the courier's list and send its identifier and canonical name, not the text the customer typed. One courier's API keeps its Bulgarian localities only in Latin script. A search in Cyrillic returns zero results. Our own transliteration of the capital produced "SOFIYA"; the courier has "SOFIA", and "SOFIYA" also returns zero. Writing our transliteration would have fixed a seaside resort and broken the largest city. The rule we kept: our candidate is only a hint; we write exactly the name the courier has. We resolve by postcode first, then by transliterated name, and answer only when one candidate remains, because some village names exist in two regions. The same integration also sent the country as a name rather than an ISO code, so every foreign order was treated as domestic. It kept working by inertia: 116 labels succeeded because their postcodes were correct and the courier fell back on the postcode. Only the orders with a non-existent postcode failed. The same API returns only 10 rows when you list countries or localities without a filter, so a map built from an unfiltered listing silently missed a whole country. **Step 5: respect field limits and what customers paste** Customers paste the whole address into the first line. One courier's API refuses the entire label when the street field is longer than 50 characters; a pasted line of 59 characters blocked the parcel. We now cut the street field at 50 and send the full address in the courier's free-text note, which the driver reads. Customers also paste the region into the city field, as in "Габрово, Габрово". If the name contains a comma, we retry with the part before it. In Poland the house number often sits in the second address line in glued forms such as `2m39` (building, separator, flat); a pattern that required a word boundary missed them and sent good orders to a person. Fixing it took the share going to a person from 2.5% to 1.0% on a test of 400 real orders. Czech addresses had the same glued-number bug. Letter case is a family too. A rule that splits a street type glued to a name worked on Title Case and broke full capitals: "ALEEA OLT" became "ale ea olt". Over 120 days, 6,401 of 250,153 orders were in capitals and 732 had a mutilated street. An earlier fix had covered two of the three letter cases. **Where we were wrong** We trusted validation endpoints. One courier's address-validation call returned VALID for three forms of the same street, including a wrong one, and its price-quote call accepted a locality whenever the name was unique nationally, ignoring the region the customer wrote. That accepts exactly the small villages that sit in a different region from the one typed. The real check is to create the shipment and read it back. We treated our own errors as the customer's. Orders sat on hold for days marked "wrong address" when the stored error was an exception in our code; after resetting the failure counter, 13 of 14 got a label on the first attempt. An office-pickup rule matched only the Cyrillic word for "office", so an order that asked for an office in Latin letters shipped to the home address instead. And the courier accepts a label with no street at all, so "the label was created" does not prove there is somewhere to deliver: 17 Bulgarian orders that named only a courier office stayed with a person. **Limits** The rescue rates we measured are ours: on the first Czech test the nomenclature rescued 86% of orders the geocoder had rejected that had a valid address. Re-running the validator over 544,336 orders, it handled 97.20% and passed the rest to a geocoder. Those numbers describe our customers, couriers and registers, not yours. Bulgaria's ceiling is lower than the other countries because there is no official street register. **Checklist** **1.** Street-level or locality-level postcodes? Decide before porting. **2.** Find the register's flaw (wrong postcodes, sentinels, missing tags) before loading it. **3.** Prove `norm(name) = name_norm` on every row; never decompose Cyrillic. **4.** Send the courier its own identifier and spelling; answer only on one candidate. **5.** Cut fields at the courier's limits and put the full text in the note. **6.** Test pasted, glued and all-capitals input. **7.** Classify your own exceptions as retryable, not as a bad address. **8.** Read the shipment back instead of trusting a validation endpoint. ### Handling GDPR erasure requests across the store, helpdesk and email tools A GDPR erasure request is a customer asking an online store to delete their personal data. We stop marketing to that person within the hour, map every system that holds a copy, delete only by exact keys, and install a deletion ledger before deleting anything, because nightly syncs rebuild what you erase. Some records, such as invoices, must be kept by law, so the reply never says "we deleted everything". This is our working method, not legal advice. **What to do when a customer asks you to delete their data** A GDPR erasure request is a customer exercising the right to erasure under Article 17 of the GDPR: asking a business to delete the personal data it holds about them. The order that works for an online store is: stop all marketing to that person first, within the hour; confirm identity without collecting more data; map every system that holds a copy; register the deletion in a ledger that re-applies it after each sync; delete by exact keys only; keep what the law requires; and tell the customer precisely what was deleted, what was kept and until when. This article describes how we handle erasure requests for online stores, written after a real case in which every trap below was actually hit. It is not legal advice. The legal references are the ones our own playbook records for Romanian stores, and the playbook itself warns against citing retention periods from memory: check the law in force. **Step 1: stop marketing before anything else** Stopping marketing is the first action, before triage or inventory. Article 21(3) of the GDPR requires processing for direct marketing to stop when the person objects, and it does not benefit from the one-month response period of Article 12(3). In our real case the customer received a promotional email 2 hours 37 minutes after asking for erasure, and opened it. Nobody had been careless. There was simply no connection between the helpdesk ticket and the email platform. Suppress the person at both ends: in the email platform, and in the store's marketing consent, because the email platform's consent record had been created by the store's customer webhook and a re-sync could subscribe the person again. Check every tool that sends messages on its own schedule. Our review app had no API for suppressing a person; we tried nine plausible endpoint names and all returned 404, so it had to be blocked by hand in its dashboard. Review requests typically go out 15 to 30 days after delivery, so a review request could otherwise arrive just after the written confirmation that messages had stopped. **Step 2: confirm identity without collecting more data** When the erasure request comes from the same email address as the order, and the name or phone number matches, that is a reasonable identity check under Article 12(6). Asking for a copy of an identity document collects more data than the request needs, which conflicts with the data-minimisation principle in Article 5(1)(c), and it delays the answer. The response deadline is one month from receipt under Article 12(3). Record the exact time the request arrived; the marketing incident above is measured from it. **Step 3: map every place the customer's data lives** A customer's personal data in a small e-commerce business is spread over far more systems than the store. Our map for one customer covered: the store platform (customer record, orders, several copies of each address, IP address and fraud-risk scores); the email platform (profile, events, predictive scores); the helpdesk (profile, conversations, internal notes, and an order-status link carrying a secret key); the data warehouse (orders with a full raw JSON copy of each order, plus per-customer aggregates such as refusal scores); the shipping database; the order management system; profitability databases with about 28 backup tables inside the live database; local mirror databases; call recordings from the phone system; error-monitoring breadcrumbs that held order payloads; and spreadsheets and shared storage. Three categories are easy to miss: raw JSON columns that hold a full copy of the order, archive tables inside a live database that no redaction procedure touches, and call recordings. Blind indexes, which are hashes of phone numbers or emails used for lookups, are personal data too: they let anyone test a guess, so they must be cleared. **Step 4: install the deletion ledger before deleting** A deletion in a downstream copy is overwritten by the next sync unless the sync knows about it. In our setup the store feeds the warehouse, which feeds derived caches rebuilt several times a day, and the courier sync refills shipping data over a 90-day window. Without a ledger, a deletion was undone within 24 hours. So the order is: write the request into a deletion ledger first, then delete. Small jobs re-apply the ledger every hour in each database, matching only exact keys from the ledger, never names. The jobs print nothing when there is nothing to re-apply, so any line in their log means a real resurrection, and the jobs double as monitoring. We accept them only after three consecutive silent runs. The store platform itself refused to redact orders from the last 6 months, so the source stays live for months. We still filed the platform's official erasure request, which starts the clock. The proof of erasure is not the delete statement; it is the check the next day that the rows did not come back. **Step 5: keep what the law requires, and say so** An erasure request does not override legal retention duties, and the reply should say which records stay and why. For Romanian stores our playbook records: invoices kept 5 years under Law 82/1991 Article 25 as amended by Law 36/2023 (the older period was 10 years, and language models still quote it); a copy of each invoice held by the tax authority's national e-invoicing system, where the store has no right to delete; the delivery record as a supporting document; the contractual core for 3 years; the legal conformity guarantee for 2 years from delivery; and the 14-day withdrawal period. The withdrawal period deserves a sentence in the reply. If the request arrives inside those 14 days, a complete erasure leaves the store unable to process a return for the same person, which is against the customer's own interest. The marketing exclusion entry is also kept, isolated, because it is what stops future sends. Check every retention period against the law in force; this list is not legal advice. **The traps we hit, and how we catch them now** **Namesakes.** For a common surname, one search returned 16 different people in one database and 11 in another. Deleting by name destroys someone else's data and turns one resolved request into two breaches. We write the list of allowed keys before the first query and never leave it; never a `LIKE '%name%'`. **A zero without a positive control.** An empty result can come from a wrong filter, an encrypted column, missing access or a scope that does not cover the case. A shared folder of 18 files had no orders at all, so its zero came from coverage, not absence. A spreadsheet of 75,000 orders did not contain the customer because it stopped 6 months earlier. The first check of one server reported no tables because the database command-line tool was not installed; reading the file another way found the data in 3 tables. First prove that the method finds some other person. **Encrypted columns.** Fields encrypted with a random IV never match a `WHERE` clause, and they fail silently as zero rows. Fetch the rows by order identifier and filter after decryption. **No delete operation.** Our order management system's API had no delete operation in any of its 151 paths, and its only address write would have pushed changes to the store and every connected app. We cleaned its database directly instead. **Where we were wrong, and what the reply must not say** Our first reply draft was invalidated by three facts we had not seen from the code: the request had never reached marketing, two sync loops would rebuild the deleted rows, and complete erasure was legally impossible. We learned the method from that case. The rules for the reply: never write "we deleted everything", because absence cannot be proven, only what was deleted. Do not claim recipients were notified unless they were. If an email went out after the request, say so; the platform's logs are as available to an investigator as they are to you. Separate what you verified from what someone told you. Tell the customer they can complain to the national data protection authority. The audit itself creates copies: API response caches, transcripts, exports. Purge the raw dumps at the end, keep the working file as evidence of a correct process, and never put real customer data into documentation, which is how data leaks. **Limits and checklist** Some things a store cannot close alone. Shared storage and spreadsheets can only be declared clean for the files actually enumerated, because an access token often sees only the files its own app created. Signed public links to invoices or labels can only be invalidated by the vendor that issued them; if the vendor cannot, document the limitation instead of hiding it. **1.** Suppress marketing everywhere, at both ends, within the hour. **2.** Check identity from the order email; do not ask for ID. **3.** Map every system, including raw JSON, archive tables, recordings and blind indexes. **4.** Ledger first, delete second, exact keys only. **5.** Verify the next day that nothing came back. **6.** List what is kept, under which law, until when. **7.** Never write "we deleted everything". ### How to write SEO blog articles for an online store without invented facts An SEO article on a store blog may only mention products that exist and are in stock, and only claims the store can prove. We ground the writer in a catalogue exported from the store, have a second agent try to refute each draft, and let a script with no model in it block publication on fake product links, banned words and invisible AI characters. **What a store blog article must not invent** An SEO article for an online store must not invent products, product links, prices, delivery promises or performance claims. Every product the article recommends has to be a real product that is in stock today, linked by its exact address, and every claim has to come from a short list of facts the store can prove. A language model will happily write a fluent article around a product that does not exist; the method below exists to stop that before it reaches the blog. SEO (search engine optimisation) here means making a page that answers a real search query well enough to be shown and clicked. A store blog article is an editorial page on the store's own domain that answers such a query and links to products. The risk is specific to stores: a wrong fact in an article is not only a style problem, it is a promise to a customer. We run this method for several store blogs, each in its own brand voice. Our first batch was 27 articles, 9 per store on three stores, published live. We do not yet have measured proof of what traffic they bring, and we say so in the limits below. **Step 1: ground the writer in the store's own catalogue** **1. Export the catalogue.** Pull the store's blogs, products and images through the store's admin API into a local file. Rebuild this export whenever stock or the catalogue changes, because an article written from last month's catalogue will link to products that have since sold out. **2. Build an index and a compact in-stock catalogue.** From the export, keep only products that are in stock and write them to a short catalogue file the writer can read: title, exact handle (the product's address on the store), the attributes the article may talk about. The writer receives this file, not a summary of it. **3. Use the exact handle from the catalogue.** The writer copies the handle, it never rebuilds one from the product title. A rebuilt handle looks right and returns a page that does not exist. **4. Keep a store-facts file.** Delivery terms, promotions, the store's guarantees: only claims the store can prove, each written once. The voice of each store lives in its own document; the facts live in one place. **Step 2: a writer and a verifier for every article** Each article is written by one agent and then checked by a second agent whose only task is to refute it: is every product link in the in-stock catalogue, is every comparison correct, does any banned word appear, does any claim go beyond the store-facts file. A draft the verifier cannot pass goes back for repair, not to the blog. The verifier must be allowed to contest the facts file itself, not only the draft. On another content project we gave writers a list of confirmed facts so they would not invent; the list contained errors, and every agent repeated them with the same authority as the true facts. It happened four times, and one error produced a question with no correct answer. Facts now live in a versioned file, each with its source and the date the source was read, and the verifiers are told to challenge it. After the workflow, a fixed script normalises the output before anything else happens. Agents sometimes return the article body with its HTML escaped as text, and publishing that raw output would show tags to readers. We never publish raw workflow output. **Step 3: a deterministic gate before publishing** The last check before publishing is a script with no language model in it. In dry-run mode it reads the whole batch and refuses any article that links to a handle missing from the catalogue or out of stock, contains a banned word, or falls outside the length limits. The same script then publishes as a hidden draft for a human to read in the store admin, and only after that goes live. Publishing is outward-facing, so a person confirms before the live step. This gate matters most when the workflow fails halfway. Long writing runs can hit usage limits, and when they do the verification stage falls back to the unverified draft. The dry-run gate is the safety net for that case, and unverified articles are also spot-checked by hand. Banned words are matched by pattern, so they trip even inside a negation. A sentence that says the product is *not* a forbidden thing still contains the forbidden word and still fails. The fix is to rephrase, not to weaken the pattern. **Step 4: remove what makes the text read as machine-written** A de-AI pass runs last, on the text that will actually be published. It removes invisible Unicode characters that generated or pasted text carries (zero-width spaces and joiners, byte-order marks, soft hyphens, narrow non-breaking spaces, every character in the Unicode format category), reports heavy use of em-dashes, and lists phrases that read as machine-written using a blocklist that ignores diacritics, so a phrase is caught with or without accents. The pass gives a cleanliness score out of 100, and we publish at 80 or above. Invisible characters and em-dashes are fixed automatically. Flagged phrases are listed but not rewritten automatically, because an automatic rewrite produces more machine-sounding text; a person rewrites them. Proper names need a deterministic check of their own. Models misspell brand and product names with confidence. In captions we generated from video, a sub-brand name came out misspelt on published posts. The correction now compares capitalised words against names taken from the product titles in the store, never from memory, and fixes them in code rather than by asking the model again. **Step 5: SEO fields and page furniture** On a Shopify store, an article's SEO title and meta description are not the article's summary. They are set as the metafields `global.title_tag` and `global.description_tag`; the summary is the excerpt shown on the blog page. We set both, and keep the title to 60 characters and the description to 160, aiming for 145 to 158. A good article SEO title starts with the strongest query term. The description gives a hook, a concrete benefit drawn from the store-facts file and a soft call to action. The hero image is never a guessed image address. It is either the main product's own image from the catalogue or an image generated with that real product photo as reference, so the product shown is the product sold. **Where we were wrong** We once assumed that about 14 published articles had plain product photos as hero images and started to regenerate them all. The images were already contextual scenes. A quick look would have shown it; regenerating would have wasted every generation. Look before a bulk change. Our first guard against generated captions that impersonate a speaker matched names in a case-insensitive way, which defeated its own capital-letter test. The first scan reported 23 broken captions that were fine. A guard is tested on good and bad examples before anyone trusts its count. The bigger SEO problem on our stores was not the blog at all. An audit found 138 of 153 products on one store sharing exactly the same description. Articles help, but thin, duplicated product pages are fixed on the product pages. **Limits** The method stops invented products, broken links, banned words and machine-written tells. It does not prove that an article ranks or sells. Google states that word count is not a ranking factor, and content written to a word-count target is one of the warning signs in its guidance on helpful content, together with the questions of who wrote a page, how it was made and why it exists. Our pipeline answers the first two for itself; whether a given article helps a reader is still a human judgement. A facts file is only as correct as the sources behind it. If a delivery promise changes and the file does not, every new article repeats the old promise. **Checklist** **1. Catalogue.** A fresh in-stock export, read by the writer as a file. **2. Handles.** Copied exactly, never rebuilt from titles. **3. Facts.** One versioned file with sources and dates, open to challenge by the verifier. **4. Verifier.** A second agent that tries to refute each draft. **5. Gate.** A deterministic dry-run on the full batch, then a hidden draft, then a confirmed live publish. **6. De-AI.** Invisible characters removed, flagged phrases rewritten by a person, score of 80 or above. **7. SEO fields.** Title and description as metafields, within 60 and 160 characters. ### How to calculate profit per product for a cash-on-delivery store Profit per product is the contribution each SKU leaves after its own costs: revenue without VAT, minus cost of goods, transport and marketing, counted on delivered orders only. Free gift units carry their real cost, transport comes from the real cost of each order with a fallback cascade, and marketing is allocated per order. The hard part is not the formula but taking each input from the right source. **What profit per product means for a cash-on-delivery store** Profit per product, in the sense we use, is contribution per SKU: `contribution = revenue − COGS − transport − marketing`, with revenue, cost of goods (COGS) and transport taken without VAT, and counted only on orders the customer actually received and paid for. Contribution is what a product leaves before fixed costs such as salaries, rent and software. A cash-on-delivery store is one where the customer pays the courier at the door, so an order that is placed but refused brings no revenue at all. One rule holds the method together: there is a single place where VAT, cost of goods, transport, marketing and delivery status are defined, and every report that computes profit imports that logic rather than rewriting it. When two reports each had their own version of the formula, they disagreed, and nobody could say which one was right. The steps below are the order in which we build profit per product. Each step names the input and the source it should come from, because almost every wrong number we produced came from a correct formula fed by the wrong source. **Step 1: count only delivered orders, and take delivery from the courier** Profit per product for a cash-on-delivery store starts with the list of delivered orders. Revenue and cost of goods are counted only on orders with a delivered status. Placed orders, cancelled orders and parcels still in transit stay out of the revenue side. The delivered status should come from the courier's verdict, not from the shop's own status. In a cash-on-delivery shop the payment status often stays pending until someone reconciles it, and a shop's own tracking can mark a parcel as delivered when the courier actually returned it to the sender. We now treat an order as closed only when the courier has given a final verdict, and we re-check everything that has no such verdict. Delivery also settles slowly. A cash-on-delivery parcel can stay in transit for days, so the current month always looks worse than it will end up. We do not judge a product on a month that is still open. **Step 2: revenue per product line, without VAT** Revenue per product is the product line's share of the order total, divided by (1 + VAT). We take the share by line value: if a line is half of the order's line value, it gets half of the order total. Using the order total rather than the list price spreads discounts and any shipping fee the customer paid across the lines that caused them. VAT is the rate of the country the order is delivered to, because cross-border stores charge the destination rate. Orders in a foreign currency are converted with the exchange rate for that month before anything is added up. Refunded orders and cash-on-delivery orders that were never collected should count as zero revenue, so the revenue figure must be the order total after refunds, not the original total. **Step 3: cost of goods on every unit, including free gifts** Cost of goods per product is the quantity on the line multiplied by the unit cost of that product, without VAT. The unit cost belongs on the product variant in the shop, and a manual override table catches products whose cost is missing or wrong. Free gift units count. A gift line has zero revenue but a real unit leaves the warehouse, so the gift line keeps its cost of goods and its quantity. In a per-product report the gift appears as its own line with zero revenue and a real cost, which is exactly what a gift is. The same applies to multi-unit promotions: a 2+1 offer ships three units for the price of two, and all three units carry cost. A product with no unit cost at all is not free. We list such products separately so that a missing cost cannot quietly turn into a high margin. **Step 4: real transport per order, with a fallback cascade** Transport per product starts at the order: the courier cost of that order's parcel, without VAT, is shared across the order's lines in proportion to their revenue. A gift line with zero revenue therefore carries no transport, and the paid product that triggered the gift carries the parcel. Real transport costs are not available for every order, so we use a cascade. **First**, the real cost the courier billed for that order. **Second**, if that is missing, the average real cost previously billed for parcels containing the same SKUs. **Third**, a flat cost per parcel for the store. Every order is labelled with the level it came from, and the report warns which products rely on estimates, because a product whose transport is estimated has an approximate profit. Courier cost tables need care. A per-parcel table can hold the same parcel twice, which multiplies the cost if you add it up, while taking only one cost per order misses return legs and extra parcels. The rule is: remove duplicates first, then sum every leg the courier actually billed. **Step 5: marketing allocated per order** Marketing per product is the advertising cost that can be traced to the product, divided across that product's delivered orders. Where an ad names an exact SKU, its cost goes to that SKU. Where an ad names a product group, its cost is shared across the group's SKUs in proportion to their number of orders. Where only the brand is known, the cost is shared across all the brand's sold SKUs, again by orders. Test campaigns are kept out of product allocation. Allocating per order means every order of a brand carries the same advertising cost, a uniform cost per acquisition (CPA). We allocate by orders rather than revenue because advertising buys orders; a revenue weighting would push the cost of cheap products onto expensive ones. How to trace ads to products when ads do not map one-to-one is a separate method that we describe in its own article. Marketing should be net of VAT, and any spend that cannot be allocated to a sold product is reported as unallocated rather than dropped, so the per-product total can be reconciled with the advertising accounts. **A worked example with hypothetical numbers** The numbers below are round and hypothetical, chosen to make the arithmetic easy. They do not come from any store we run. One delivered order: product A at 120 including VAT, plus a free gift unit of product B. VAT is 20%. Unit cost of A is 30, unit cost of the gift is 5. The courier billed 12 without VAT for the parcel. Product A had 100 delivered orders in the month and 1,500 of advertising traced to it. **Step 1.** Revenue without VAT: 120 ÷ 1.2 = 100, all on line A, since the gift line has zero value. **Step 2.** Transport: the whole 12 goes to line A, its share of revenue. **Step 3.** Marketing per order: 1,500 ÷ 100 = 15 on line A. **Step 4.** Line A: 100 − 30 − 12 − 15 = 43. Line B, the gift: 0 − 5 = −5. The order leaves 38. If the gift line is dropped, the order appears to leave 43, and the gift stock disappears from the books without a cost. If transport is taken from a flat estimate instead of the real 12, product A moves by the difference on every order. **Where we were wrong** **We trusted a derived cache for delivered orders.** A summary table built from the profitability engine undercounted delivered orders on a recent window by about 1.6 times compared with the delivery source, which made profitable stores look like losses. We now confirm delivered orders, cost of goods and spend at their sources before any decision about money. **We skipped free gift lines.** Our per-product report used to leave out lines with zero revenue. The gift's cost vanished, and products that came with a gift looked better than they were. **We read an intermediate courier status as a final refusal.** A status meaning the parcel was back at the courier's office was mapped as refused, and the parcel then left the re-tracking queue, frozen. Orders that were later delivered sat in the refused column. **We let the shop's own tracking decide delivery.** When the shop began tracking parcels itself, returned parcels with a delivered event in their history were counted as delivered, and a nightly resync rewrote correct statuses with empty ones. **Limits, and a short checklist** Profit per product is a contribution, not net profit: fixed costs are excluded. Our per-product report charges transport only on delivered orders, while the brand-level profit and loss charges every parcel that left, delivered, in transit or refused. A product that is refused often therefore looks better per product than it is, and its refusal rate has to be read next to its contribution. Marketing allocation is a model, and its quality depends on how well ads name their products. Checklist: delivered orders only, from the courier's verdict; revenue as a line's share of the order total after refunds, without VAT at the destination rate, converted to one currency; cost of goods on every unit, gifts included, missing costs listed; transport real per order, then SKU average, then flat, with the source labelled; courier rows deduplicated before summing; marketing allocated per order, tests excluded, unallocated spend reported; a month judged only once delivery has settled. ### How to allocate ad spend to products when ads don't map one-to-one To allocate advertising cost to products, read cost at the level of the individual ad, match each ad to a product through a fixed ladder of rules (exact SKU code, then keyword, then account), split shared ad accounts by a token in the campaign name, and divide what remains across products by number of orders. Every rule fails silently in a specific way, so the method is mostly about catching those failures. **Why ads don't map one-to-one to products** Allocating ad spend to products means deciding, for every unit of advertising cost, which product it was spent to sell. The direct answer is a precedence ladder: an exact product code in the campaign or ad name wins; otherwise a product keyword in the name assigns the cost to a product group; otherwise the ad account assigns it to a brand; and group or brand cost is then shared across sold products by number of orders. A ladder is needed because ads and products rarely line up. One campaign can promote a whole catalogue. One ad account can run campaigns for several stores. An account opened for one brand can later run another brand's budget. Test campaigns spend money on products that may never sell. If spend is assigned by account name alone, all of these cases land on the wrong product without any error. Product-level ad cost feeds profit per product. Without it, a product's contribution has no marketing line, and every product looks more profitable than it is. **Step 1: read cost at ad level, keep totals at account level** Product-level allocation needs the cost of each individual ad together with its campaign name and ad name, because the product is often named only in the ad, not in the campaign. Data warehouses frequently store advertising cost per account per day, which carries no product signal at all. We pull ad-level cost directly from the advertising platforms. Ad-level cost does not always add up to the account total. Catalogue and automated campaigns do not always split their cost per ad, so the ad-level sum can fall short of what the account was charged. Our rule: ad-level data decides how cost is divided between products, and the account-level figure stays the authority for the total. Two more details at this step. Advertising accounts bill in different currencies, so cost is converted with the exchange rate of each day before anything is added. And raw cost is ingested without filtering by brand; filtering at ingestion hides real cost, while an unfiltered feed shows unmapped cost as unmapped, where an audit can see it. **Step 2: a precedence ladder from exact code to account** **1. Exact product code.** If the campaign or ad name contains a SKU code, the cost goes straight to that SKU. We accept the code only if it matches a SKU the store actually sold, so a typo cannot create a phantom product. Some codes in names are agency tags rather than products, and those are treated as noise. **2. Campaign keyword, then ad keyword.** A rule table maps keywords to product groups. Campaign-name keywords are tried first; ad-name keywords come on a separate, later step. **3. Account.** For an account that sells a single brand or category, the whole account maps to that brand. For an account that sells many products, we do not use an account fallback at all: cost that no product rule catches stays unmapped, because spreading it over the whole brand would hide the mapping gap. **Tests.** A campaign whose name marks it as a test goes into a test group that is excluded from product allocation and reported separately. A platform report that includes tests reads higher than the sales figure, and the gap is exactly the test cost. **Step 3: matching rules for keywords** Keyword matching for ad names has to survive how people type. We remove diacritics from both the keyword and the name before comparing, so a keyword written with Romanian letters still matches a campaign typed without them. When several keywords match, the longest keyword wins, so a specific product name beats a generic word it contains. Short keywords need word boundaries. A keyword of three characters or fewer matched as a plain substring hits inside unrelated words and random ad identifiers. The hyphen counts as part of the boundary, so a two-letter store code does not match the start of a hyphenated product name. The word TEST also needs a boundary: as a substring it caught real products whose names contained TESTER, and those products then showed zero marketing. Some keywords must be restricted to campaign names. Ad names that were duplicated in the platform interface carry the words `Copy of`, so a two-letter code like OF, matched on ad names, routed cost to the wrong store. That code is now allowed only on campaign names. **Step 4: shared ad accounts are split by a campaign-name token** A shared ad account runs campaigns for more than one brand. The split rule is a campaign-name token. Each brand on a shared account has a word, or a pattern, that must appear in its campaign names, and the brand takes only the campaigns containing that token. On an account dedicated to one brand, the brand takes everything. An empty token on a shared account means the brand captures every campaign on the account. That is the most common attribution bug we have seen: one brand silently absorbed another brand's campaigns, which were then counted twice, once under each brand. When several brands share an account, the most specific token wins. Account names are not proof of brand. An account can be handed from one brand to another on a given date, so the rule lives on the campaign name, not the account. An account can also be renamed in the platform while it keeps running the same brand; we read account names live and log every rename, and we check the campaigns on a renamed account before moving any cost. **Step 5: from product groups to SKUs, per order** Once cost sits on a product group or a brand, it is divided across the SKUs sold in that group or brand in proportion to their number of orders. Every order then carries the same advertising cost, a uniform cost per acquisition (CPA). A SKU sold in two stores receives cost from each account that advertises it. Catalogue campaigns that promote many products are handled per day: the cost of each day is shared across the products that received orders in that store on that day. If a store had no orders that day, the monthly order mix is used; if that is empty too, the cost is reported as not spread rather than lost. The key that decides the store must include the campaign, not only the account, because on a shared account a catalogue campaign for one store would otherwise be credited to whichever store spends most on that account. **A worked example with hypothetical numbers** The numbers below are round and hypothetical, chosen to make the arithmetic easy. They do not come from any account we run. One day on a shared account, four campaigns. A campaign whose ad names carry SKU code `SKU-0101` costs 400: all 400 go to that SKU. A campaign with the keyword for a towel group costs 300; the group's two SKUs had 30 and 10 orders, so they take 225 and 75. A catalogue campaign costs 200; the store had 50 orders that day, so each order carries 4 and each product takes 4 times its orders. A test campaign costs 100 and goes to the test group, outside product allocation. Assigned by account name, all 1,000 would have landed on one brand, and the products would have carried cost they did not cause. **Where we were wrong, and the limits** **We edited a cache instead of the source.** The brand-to-account mapping lives in a shared sheet, and local files are only cached copies of it. Editing one copy by hand looked like a fix, while another script read a different copy; the source already had the right rows and only needed a sync. **A rule fix broke correct matches.** Adding a word boundary to a short code would have pushed correctly attributed campaigns onto an account fallback, so the specific campaign rule had to be added first. **An audit lied by finding nothing.** A script looked up the platform under a different key than the rule table used, received an empty list, and reported that no short keyword ever fired. Limits: the method reaches about 99% of cost mapped to a product or group, and the rest remains unmapped until someone adds a rule. Ad-level cost undercounts automated catalogue campaigns. Allocation by orders is a model of who caused a sale, not a measurement. Naming discipline is what makes the method possible: a SKU code in every ad name, one token per brand on shared accounts, and test campaigns marked as tests. Checklist: ad-level cost with campaign and ad names; totals from account level; currency per account per day; exact SKU, then keyword, then account; diacritics removed, longest keyword wins, boundaries on short keywords; tests excluded; tokens on every shared account; live account names; per-order allocation; unmapped cost reported, never dropped; simulate a rule before applying it. ### How to compare store profitability across EU countries: VAT rates, currencies and exchange rates To compare stores that sell in different EU countries, remove each destination country's VAT from revenue, convert every order from its own currency at a dated exchange rate, take costs without the VAT on their own invoices, and only then compare contribution per delivered order. Most of our cross-country errors came from a wrong VAT divisor, a stale exchange rate or a cost read in the wrong currency. **The short answer: contribution per delivered order, without VAT, in one currency** Store profitability can be compared across EU countries only after three normalisations: revenue without the VAT of the country where the parcel is delivered, every amount converted into one reporting currency at a dated exchange rate, and costs taken without the VAT charged on the supplier's invoice. The comparable figure is contribution per delivered order: revenue minus cost of goods, shipping and marketing, all without VAT. We sell cash on delivery in Romania, Czechia, Poland, Bulgaria, Slovakia and Hungary, and also in Moldova, which is outside the EU. Cash on delivery (COD) means the customer pays the courier at the door, so only delivered orders count as revenue. Every rule below lives in one shared profitability library that all our reports import, so two reports cannot disagree about VAT or currency. The formula is `contribution = paid ÷ (1 + destination VAT) − goods − shipping − marketing`, counted on delivered orders, with goods and shipping already net of the VAT on their own invoices. **Step 1: remove each destination country's VAT from revenue** Revenue from a distance sale to a consumer in another EU country carries the VAT rate of the destination country, not the seller's home rate. The VAT divisor therefore changes per order. The standard rates held in our profitability code are Romania 21%, Bulgaria 20%, Czechia 21%, Poland 23%, Hungary 27%, Slovakia 23% and Croatia 25%. Those standard rates are a snapshot: VAT rates change, some goods take reduced rates, and every rate must be re-checked with the tax authority of the country before use. A hypothetical example shows why the divisor matters. Two orders are both worth 1,000 without VAT. In Czechia, at 21%, the customer pays 1,210. In Hungary, at 27%, the customer pays 1,270. Divide the Hungarian order by the home divisor 1.21 instead of 1.27 and the report shows about 1,050 of revenue instead of 1,000. The Hungarian store then looks about 5% better than it is, on every single order, and no individual row looks wrong. A single home-country divisor works for a domestic business, which is why it survives in spreadsheets. Our older profitability sheets divided all revenue by the home rate, which overstated revenue in the higher-rate countries. **Step 2: convert from the order's own currency at a dated rate** Convert each order from the currency recorded on that order, at the rate for the order's date or month. Do not convert per store. A multi-market store can take orders in several currencies, and a country can change currency: Bulgaria moved from the lev to the euro on 1 January 2026, the lev is now derived from the euro at the fixed rate of 1.95583, and the changeover day was the only day in our data with two currencies in one store. A daily rate is exact for each order; a monthly average is simpler, and a monthly rate per currency is what our profitability engine stores. Two rules matter more than that choice. The open month is refreshed from the central bank's official rates every time the report runs. A closed month stays frozen, because it is the figure that was reported, and only missing currencies are filled in. Never store a fallback exchange rate. On the first day of a month the official source has no rate yet, and our code once wrote an approximate hard-coded rate into the rate table. That value stayed for the whole month, because nothing was allowed to overwrite it. A fallback rate now lives in memory only, with a warning. Spreadsheet currency functions fail as well. In one of our reporting sheets a live exchange-rate function returned an error for a few minutes; the error had been stamped into the formulas as a literal multiplier, so it became permanent in the history. Wrap a live rate function in a fallback and reference the rate cell instead of copying its value into formulas. **Step 3: know which currency every cost is in** Revenue, cost of goods and advertising do not have to share a currency, even inside one store. In our stores the cost of goods is recorded in the home currency on every store, including the stores that sell in another currency. A cost of 20 next to a price of 11.99 is therefore not a loss: the two numbers are in different currencies. On a multi-market store, revenue arrives in each market's local currency while cost is held in the store's base currency, so revenue and cost need different exchange rates. Advertising accounts have their own currency, and that currency cannot be guessed from the numbers. We once inferred from a plausible-looking cost per order that an ad account was in the home currency; the account was in US dollars. Read the currency from the ad account's settings, and keep it next to the account in the mapping table. **Step 4: shipping gross versus net, and why international parcels cost more** Courier invoices include VAT. The VAT on a cost follows the invoice you receive, not the destination of the parcel. If the courier that carries an international parcel invoices from the home country, divide its cost by the home rate, even though the revenue of that same order is divided by the destination rate. Use the real shipping cost per order, one main parcel per order. Our label tables keep one row per label and labels are often duplicated, so summing them multiplies the cost. Where the real cost is missing, our shipping cascade falls back to a median for that courier, and then to a flat rate. International parcels cost more than domestic ones. In one review of our own contribution, charging foreign parcels at the domestic rate was one of four corrections, all of which lowered contribution. Compare countries using each country's own parcel cost. A refused international parcel still cost the outbound trip. Whether the courier also bills the return leg depends on the contract. We changed this rule more than once: one version of our model charged every refused international parcel twice, and the current model charges one leg, because the international rate it uses is already the higher one. Read your own contract rather than copying either rule. **Invoicing under OSS: the basics we check** OSS (the EU One-Stop Shop) lets a seller declare the VAT due on distance sales to consumers in other EU countries through one quarterly return in its home country, instead of registering in each country. The invoice to the consumer carries the destination country's rate, and that includes the shipping line charged to the customer. Invoicing software usually holds each OSS rate as a named rate per country. If the rate is not set up under that country's name, the invoice is refused; if the software silently picks the home rate, the invoice is issued with the wrong VAT. We check OSS on real issued invoices, not on settings. A small script downloads a sample of invoice documents, reads the country and the VAT rate printed on each, and compares them with the country table. The sample stays small and slow, because invoicing platforms throttle document downloads. A settings screen does not show which rate actually lands on the invoice; only reading issued invoices does. A marketplace order feed can report a VAT rate of 0 on its lines as a configuration artefact; that does not make the sale exempt. Outside the EU, OSS does not apply. For Moldova the sale is either an export or a delivery from a local warehouse, and the invoice must state its legal basis; which one applies is a decision for an accountant. Our invoicing software rejected invoices that lacked that mention. Sales to businesses with a VAT number follow other rules, which this method does not cover, and none of this is tax advice. **Where we were wrong** **We divided all revenue by the home VAT rate.** Higher-rate countries looked better than they were. The VAT divisor now comes from the order's destination country. **We stored a guessed exchange rate.** A fallback written on the first day of a month stayed all month, and when we added a store in a new non-EU currency the first report used an estimated rate marked to be refined. Estimated rates now never reach the rate table. **We guessed an ad account's currency from its cost per order.** The guess was wrong. Currency is read from the account, never inferred. **Limits, and a short checklist** Contribution per delivered order leaves out fixed costs that differ by country, such as customer service in another language. Delivery rates differ by country and settle at different speeds, so a fresh window flatters no one equally. Exchange-rate movements change the comparison from month to month; to compare operations rather than currencies, recompute both months at one rate. Tax rules change, and the rates above must be verified. Checklist: revenue divided by the destination country's VAT, re-checked; every order converted from its own currency at a dated official rate; open month refreshed, closed month frozen, no stored fallbacks; cost currency and ad-account currency read, not assumed; shipping net of the VAT on the courier's invoice, one main parcel per order; refused parcels charged the legs the contract bills; OSS verified on real invoice documents; non-EU sales invoiced with a legal basis chosen by an accountant. ### Does a buy-two-get-one-free promotion make money? A per-order method A buy-two-get-one-free (2+1) promotion makes money when a three-unit order, after the cost of all three units, one parcel and the advertising cost of one order, still leaves positive contribution. Measure it per order, split by units per order, on delivered orders only, with the free unit charged at full cost. **The short answer** A 2+1 promotion makes money if the average delivered three-unit order still leaves positive contribution after VAT, the cost of all three physical units, shipping and the advertising cost of one order. Contribution per order is what the customer paid without VAT, minus cost of goods, minus shipping without VAT. The free unit has no price, but it has a full cost, and leaving that cost out is the most common way a promotion looks profitable on paper. We sell several product lines cash on delivery, and on some of them 2+1 is the standard offer, so the promotion is effectively the business. Our promo report splits delivered orders into tiers by units per order (1, 2, 3 and 4 or more) and shows contribution per order in each tier. The three-unit tier is the one to read. **Why the order, not the unit, is the unit of analysis** Shipping and advertising are paid per order. One parcel carries three units for about the cost of one, and an ad campaign buys an order, not a single product. A promotion that moves customers from one unit to three spreads those two per-order costs across more revenue. That is the whole case for 2+1, and the same arithmetic shows when the case fails: every promotional order also carries the full cost of one unit that brings no revenue. On stores where most orders are bundles, per-product rules mislead. A product priced well below the average order value is not a weak product when customers buy it three at a time; the order is the economic unit, so decisions about a 2+1 store should be taken on orders. **The method, step by step** **1. Delivered orders only.** Use courier statuses, over a window long enough for parcels to finish their journey; our report defaults to 90 days. Refused cash-on-delivery orders are not revenue. The shipping wasted on refusals belongs in the delivery analysis and must be added back before the final verdict. **2. Count physical units, including the free one.** Put each order in a tier: 1, 2, 3 (the 2+1) or 4 or more units. **3. Revenue is what was actually paid, without VAT.** Take the order total and divide by (1 + VAT). Do not rebuild revenue from list prices. In order data the free unit often keeps its list price with a 100% discount, and the discounted line total already reflects that, so subtracting the discounts again counts them twice. **4. Cost of goods on every physical unit, without VAT.** Real per-product costs are best. Our report can also estimate cost as a percentage of list value; with that estimate the absolute margin is approximate, but the comparison between tiers stays reliable. **5. Shipping, real and without VAT, per order.** **6. Contribution and margin per tier.** `contribution = paid ÷ (1 + VAT) − goods (all units) − shipping ÷ (1 + VAT)`; margin = contribution ÷ revenue without VAT. Our report flags a tier green when its margin is at least 10%, yellow below 10%, and red when it loses money. **7. Subtract advertising per order.** Spread advertising evenly across delivered orders, because ads buy orders, not units, then compare that cost with the contribution of the tier that holds most orders. **A worked example with hypothetical numbers** The numbers below are round and hypothetical. Inputs: list price 50 per unit including VAT, VAT 25%, cost of goods 10 per unit without VAT, shipping 15 per parcel without VAT, free shipping for the customer, advertising 30 per delivered order. **One unit.** Revenue: 50 ÷ 1.25 = 40. Contribution 40 − 10 − 15 = 15. After advertising: 15 − 30 = −15, a loss on every single-unit order. **2+1, three units.** The customer pays 100. Revenue: 100 ÷ 1.25 = 80. Goods for three units: 30. Contribution 80 − 30 − 15 = 35. After advertising: 35 − 30 = 5, a small gain. **The naive version.** Charging goods only for the two paid units gives 80 − 20 − 15 = 45, which overstates every promotional order by 10, the cost of the free unit. **The general rule.** Compared with a single-unit order, the 2+1 order adds one paid unit of revenue (40) and two units of cost. It earns more only while the unit cost is below half the unit price without VAT, here below 20. Raise the unit cost to 25 and the single-unit order leaves 40 − 25 − 15 = 0, while the 2+1 order leaves 80 − 75 − 15 = −10: the promotion now loses money before any advertising. **What the per-tier table cannot tell you** A per-tier table describes the orders you got, not the orders you would have got without the promotion. A customer who would have paid for two units anyway now receives a third for free. In the hypothetical example two units at full price leave 80 − 20 − 15 = 45, so for that customer the promotion costs exactly one unit's cost, 10. Only a test with and without the offer, or a price change, can show how many customers were moved up and how many were simply given a unit. Shipping fees blur the picture. If the store charges shipping below a free-shipping threshold, the order total includes that fee, which roughly pays the courier and is not product revenue; on those orders the discount also looks smaller than it is. Whether the three-unit bundle crosses the free-shipping threshold is a pricing decision in its own right. A practical detail: set the list price so that the effective price per unit, two thirds of the list price, is a round number. **Where we were wrong** **We subtracted the discount twice.** Our product analytics took the discounted line totals and then subtracted the discount allocations again. On a 2+1 store that under-reported revenue by about 36% until we compared one order line by line with the store's own subtotal. **We read discounts from the lines only.** Discounted unit prices catch line discounts such as the free unit, but not discounts applied to the whole order. Complimentary orders with a full order-level discount entered our reports at full price. We now scale line prices to the order subtotal. **We trusted catalogue cost shares.** The catalogue assumes one unit per order; with 2+1 the real share of goods in revenue was higher, so the true room for advertising was smaller. **We assumed the promotion travels with the order.** Automatic store discounts are not applied when staff create a replacement order by hand, so replacements came out at the wrong value until we copied the discount from the original order. And a calculated shipping of zero can mean that no rate was chosen, not that shipping is free. **We planned a new market on the wrong order value.** For one launch the planned order value was higher than a three-unit order at the local market price; at the real price the 2+1 left almost no room for advertising. The per-order arithmetic now comes before the launch, not after it. **Limits, and a short checklist** The method measures contribution, not profit: fixed costs, returns after delivery and the cost of refused parcels sit outside it. A cost estimated as a percentage of list value gives approximate margins. Averages per tier hide products whose unit cost is close to half the unit price, which is where 2+1 turns from gain to loss. Checklist: delivered orders only, from courier statuses; tiers by physical units including the free one; revenue from the order total without VAT, discounts counted once; goods charged on every unit; shipping per order without VAT; advertising spread per order; refusal shipping added back; the free unit's cost compared with half the unit price without VAT; a test with and without the offer before calling it a success. ### Customer segments, RFM and lifetime value for cash-on-delivery stores: why you must use delivered orders RFM segmentation, cohort retention and customer lifetime value only mean something on a cash-on-delivery store if they are computed on delivered orders, because a parcel refused at the door is not revenue. We score every customer by recency, frequency and monetary value from delivered orders only, per store and per currency, and we find a single customer across systems by the last nine digits of the phone number. The hard part was not the formulas but making sure the search and the data use the same normalisation. **Why customer analytics on a cash-on-delivery store must use delivered orders** On a cash-on-delivery store, customer segments, RFM scores and lifetime value must be computed on **delivered** orders only. A cash-on-delivery order is paid in cash to the courier at the door, so an order the customer refuses brings no money in. If you build segments from the raw order list of the store platform, every refused parcel counts as a purchase, and a customer who refused several parcels looks like a loyal, valuable buyer. **RFM** stands for recency (how long since the last order), frequency (how many orders) and monetary value (how much the customer paid). **Lifetime value (LTV)** is the average revenue a customer brings over the whole relationship. **Cohort retention** is the share of customers, grouped by the month of their first order, who buy again in later months. On a cash-on-delivery store, all three must read from the courier's delivery status, not from the order being placed. We run this analysis on our delivery database, where each order carries an aggregated delivery status, and we filter on `status = 'delivered'`. The store platform's own order list is the wrong source here: it includes refusals, and in our case it also includes cancellations. **Step 1: pick one customer identity and one currency** **1. Identity.** RFM needs one row per customer. In our delivery data the identity key is the customer email, which covers about 355,000 unique customers across about 443,000 orders. Orders with an empty email are excluded, so a customer who never gave an email stays anonymous and their orders are not linked together. Email coverage is good, but it is not complete. **2. Currency.** Monetary value is stored in the currency of each store. A store that sells in Romanian lei and one that sells in Czech koruna cannot be added together, so customer analytics runs per store. Running across all stores is useful only for counting customers, never for value. **3. Refunds.** Our customer analytics does not subtract partial refunds. The order total is the value of the delivered order. That is acceptable for ranking customers; for profit you need the full profitability pipeline, which is a different method. **Step 2: score RFM with quintiles and map scores to segments** Each customer gets a score from 1 to 5 on recency, frequency and monetary value, using quintiles (`NTILE(5)` in SQL): the most recent fifth of customers get 5 for recency, the most frequent fifth get 5 for frequency, and so on. Quintiles adapt to each store, so you do not have to guess what "recent" means for a store that sells perfume versus one that sells furniture. We then average frequency and monetary into one score (FM) and map the pair to six segments. **Champions**: recency at least 4 and FM at least 4. **Loyal**: recency at least 3 and FM at least 3. **New or promising**: recency at least 4 but FM below 3. **At risk**: recency 2 or lower but FM at least 4, meaning valuable customers who have stopped buying. **Lost**: recency and FM both 2 or lower. Everyone else is **needs attention**. The segment that pays for the analysis is **at risk**: customers who spent a lot and have gone quiet. They are the first target for a win-back email or SMS flow, and reactivating them costs far less than finding new customers. **Step 3: cohort retention, LTV per cohort and a simple forecast** **Cohort retention.** Group customers by the month of their first delivered order, then count what share of each cohort bought again in month 1, month 2, up to month 6. Month 0 is 100% by definition. A flat retention curve is a product or post-purchase problem, not an acquisition problem. **LTV per cohort.** For each first-order month we report the number of customers, orders per customer, average delivered revenue per customer, and the repeat rate (share of customers with 2 or more delivered orders). On one of our stores the repeat rate ranged from 4.9% in the youngest cohorts to 28.5% in the oldest. Older cohorts always look better because they have had more time to buy again, so read them as the ceiling a young cohort may reach, not as its current value. Cohort LTV is the realistic upper bound for what you can pay to acquire a customer. **Forecast.** We take delivered orders and revenue per week over the last 52 weeks, drop the current partial week, average the last 8 weeks, and compare the last 4 weeks with the 4 before them to get a trend. The trend is applied at half strength per week ahead. This is a deliberately simple model with no fine seasonality, good enough for stock and budget planning, not for a peak season. **Step 4: find one customer by phone across formats** To find a single customer across systems by phone number, strip every non-digit and compare only the **last 9 digits**. A Romanian mobile number arrives as `0700000000`, `40700000000`, `+40 700 000 000` or with dashes, depending on the checkout form, the courier and the agent who typed it. The last 9 digits are the same in all of them. Our customer-service lookup searches both the billing phone and the shipping phone with that suffix, and returns every order from every store. From that list the lookup computes lifetime value from **delivered** orders only, the refusal rate, and a flag for serial refusers: a customer with 2 or more refusals, or a refusal rate of 50% or more over at least 2 completed parcels, is offered card payment only, never cash on delivery again. Phone is the better key for customer service on cash-on-delivery stores, because the phone is the one field the courier needs and the customer cannot skip. Email is the better key for the RFM batch, because it is stored consistently in the delivery data. We accept the gap rather than pretend one key covers both jobs. Linking a support ticket to a customer is harder. In our helpdesk, email tickets link directly (27% of tickets). In private social messages (about 12%) the customer usually types an email or phone number when asked, which we extract with a pattern match. Comments on ads (60% of tickets) carry only an anonymised, page-scoped ID, so the person behind them cannot be identified, only the store. **Where we were wrong: the search normalisation was not the data normalisation** Our worst identity bug was not in customer data but in an address lookup, and it applies to any search. The search function folded text one way, by decomposing characters and dropping accents, while the stored column had been normalised another way. In Bulgarian, a Cyrillic letter that decomposes lost its mark, so 303 of 6,195 localities could never be found. In Polish, a letter with no decomposed form kept its stroke while the stored name had a plain letter, so 6,382 of 35,714 names were unfindable, including well-known cities. A search that asks the wrong question always answers "nothing found", and that looks exactly like "nothing exists". The fix is a test, not a better fold: when you compare input with a precomputed column, prove the two normalisations are the same function by counting rows where `norm(name) <> name_norm`. If the count is not 0, you are searching in a different language from the one the data was written in. Our last-9-digits rule for phones is safe precisely because the stored phones are matched with the same rule, as a suffix. The second mistake was trusting a source because it looked fresh. Our analytics warehouse had orders up to the current day, yet for one store it was missing about 15% of orders compared with the live store. A defect-rate report built on a truncated source overestimated the rate by about 38% before we moved it to the delivery database. **Limits and a short checklist** This method ranks customers; it does not measure profit. Partial refunds are not subtracted, customers without email are invisible to the RFM batch, and the forecast ignores seasonality. Quintile scores are relative, so a store with very few repeat buyers will still have a top fifth called "Champions". **Checklist.** Filter on delivered orders, not placed orders. Run per store and per currency. Choose one identity key per job and know what it misses. Score R, F and M with quintiles and act first on the at-risk segment. Read old cohorts as a ceiling. Match phones on the last 9 digits of both sides. Before trusting a "not found", prove the search and the stored data are normalised by the same function. ### How to build "frequently bought together" from your own order data A "frequently bought together" block can be computed from your own orders with market-basket analysis: count how often two products appear in the same order and rank the pairs by lift, which says how much more often they sell together than chance would predict. We compute pairs per store over the last 180 days, from a source that holds almost every order, with minimum counts that keep rare products from producing absurd lifts. The method finds real pairs; turning them into bundles was harder than the maths. **What "frequently bought together" means and how to compute it** "Frequently bought together" is a list of products that customers actually put in the same order, ranked by how much stronger the association is than chance. You can compute it from your own order data with **market-basket analysis**: for every pair of products, count the orders that contain both, then rank the pairs by **lift**. No recommendation service is needed, only the order lines with a product identifier. Three numbers describe a pair of products A and B. **Support** (co-occurrence) is the number of orders that contain both. **Confidence** from A to B is the share of orders with A that also contain B: `co / orders_with_A`. **Lift** compares that with what chance would give: `lift = co × total_orders / (orders_with_A × orders_with_B)`. A lift of 1 means the two products meet as often as two random products would; we treat a lift above 1.5 as a real association. Confidence is directional and lift is not. If a cheap accessory is in almost every order, every product will have high confidence towards it, but its lift will be close to 1. That is why we rank by lift and show confidence alongside it. **Step 1: build the baskets from a complete source** **1. One row per product per order.** Expand every order into its line items and keep one row per distinct SKU per order. Quantity does not matter here: an order with several units of the same product is still one basket containing that product. Product names change and get translated, so pair on the SKU and look up the display title afterwards. **2. Exclude cancelled orders.** A cancelled order never left the warehouse and often duplicates another order. We keep refused cash-on-delivery orders in this analysis, because the question is what customers choose together, not what they paid for. That is a deliberate difference from customer value analysis, where only delivered orders count. **3. Use the most complete source you have.** Our first version read baskets from the analytics warehouse. For one store that warehouse was missing about 15% of orders compared with the live store, even though it looked fresh. We moved the default source to the delivery database, which holds about 99% of orders and answers instantly, and kept the warehouse only as a fallback. **Step 2: set thresholds before you trust a lift** Lift is unstable on small numbers. Two products that each sold only a handful of times and happened to share an order can show an enormous lift. So we filter before ranking. Our defaults are: a window of 180 days, at least 20 orders per product before it is considered, at least 15 orders containing the pair, and a lift of at least 1.5. The order of the filters matters. First drop products below the minimum order count, then compute the total number of orders *among the products that remain*, then count pairs. If the total includes orders made only of filtered-out products, every lift is inflated by the same factor and the ranking looks better than it is. Very high lifts still appear, and they are usually true. On our perfume stores, two fragrances from the same range showed a lift of about 240, and another pair appeared together in 297 orders. A high lift between two mid-volume products typically means customers see them as a set. On a home and garden store the pairs were just as recognisable: storage shelving units bought together, variants of the same ceiling light, sets of storage boxes, and a bedside crib with a baby bath. **Step 3: ask for one product's complements** The pair list is useful for a merchandiser; a product page needs the reverse question: given product A, what should we suggest? We filter the pairs that contain A and rank the other product by lift, showing the co-occurrence count and the confidence from A. The top three complements per hero product are what we would put in a "frequently bought together" block. The same list feeds four uses. A block on the product page. A post-purchase email that suggests the complement of what was just bought. The choice of the free item in a buy-two-get-one promotion, picked from high-lift complements of what is in the cart. And bundle or "complete the set" offers. **Where we were wrong: the bundle was the hard part** The first mistake was the source, described above: the warehouse looked complete and was not, and a recommender built on missing orders misses exactly the stores where the gap is largest. The second lesson came when we tried to sell high-lift pairs as bundles. A native platform bundle, where the components reduce stock automatically, can be created through the admin API without a separate bundles app; we converted 15 kits that way on one store. But on stores where a cash-on-delivery form app creates the order on the server instead of through the normal checkout, the platform rejected the bundle variant, and one store had already dropped native bundles for that reason. For those stores we built kits as plain products picked at order time, with the list of components stored on our side. The same function unpacks a kit into its components in every place that needs it: the scanner in the warehouse, order ingestion, stock reservations and the stock deduction at shipping. A kit known only to the store and not to the warehouse would ship as one unknown item. The improvement we planned and have not built is ranking pairs by lift multiplied by the real profit per SKU, so that we push the complements that make money and not only the frequent ones. Until then, the list says what sells together, not what is worth promoting. **Limits** Market-basket analysis only sees what customers already buy together. A product that is never shown next to another cannot form a pair, so the method reinforces existing habits rather than discovering new ones. Stores where most orders contain a single product produce very few pairs. Lower the minimum pair count or widen the window, and accept that the result is weaker. Our query does not separate a free gift line from a paid line. If a promotion adds a fixed gift to every order, that gift will pair with everything, and you should exclude it before reading the list. Correlation is not cause. A high lift says two products are chosen together, not that suggesting one will sell the other. Test the block on the page before assuming it works. **A short checklist** Pair on SKUs, one row per product per order, cancelled orders excluded. Read baskets from the most complete order source and check its coverage against the live store. Filter products and pairs by minimum counts before computing lift, and compute the total from the remaining products. Rank by lift above 1.5 and show co-occurrence and confidence beside it. Remove fixed gift lines. Before selling a pair as a bundle, confirm the bundle survives your checkout path and your warehouse can see its components. ### How to add product and UGC videos to Performance Max: from clip to asset group Performance Max does not take video files: every video asset is a YouTube video referenced by its ID, so the path is clip, checked MP4, upload to the brand's own YouTube channel, then a link to the asset group. We keep clips at 11 seconds or longer, deliver 16:9, 1:1 and 9:16 versions, attach a handful rather than dozens, and verify each step by reading the result back. Most of what went wrong for us came from steps that reported success without doing the work. **What it takes to put a video into Performance Max** To put a video into a Google Ads Performance Max campaign, you upload it to YouTube and link that YouTube video to an asset group; Google Ads does not host raw video files. Performance Max is the Google Ads campaign type that serves one set of assets (text, images, video) across Google's surfaces, and an asset group is its bundle of assets for one theme. A video shorter than 10 seconds is rejected at linking with the error `YOUTUBE_VIDEO_TOO_SHORT`, so we only consider clips of 11 seconds or more. Our videos come from creator videos (user-generated content, or UGC) handed to the media buyer, from montages rendered from the store's own photos and live prices, and, experimentally, from clips generated out of one product photo. All follow the same path: check, format, upload, attach, verify. **Step 1: hand creator videos to the media buyer as a list, not a folder** Creator videos arrive in a shared drive, one folder per brand. The media buyer does not work from the folder: every new video becomes a row in a shared sheet with a file link, its subfolder and a status, so nothing is missed or used twice. Images are skipped. Deduplicating creator videos needs two keys. The same content is often uploaded again under a new file ID, so a file is dropped if its ID is already in the sheet **or** if a file with the same normalised name (lower case, extension removed, spaces collapsed) is. The drive's creation date is not reliable for old archives: a backlog imported in one day gets that day's date on every file, so such folders are treated as legacy and skipped. The handoff runs as a dry run first, printing the planned rows and counts, and only then writes; the sheet accepts at most 10 linked-file chips per request, so links go in chunks of 10. **Step 2: convert the clip to an MP4 every platform accepts, and check the result** Clips often arrive as WebM (VP9 video with Vorbis or Opus audio) or MOV, and merely changing the container gives a file that editors and upload forms refuse. We re-encode to H.264 video with AAC audio, set the pixel format to `yuv420p` (without it some phones and players show a black screen), and move the index to the start of the file with `+faststart` so playback starts before the download ends. The MP4 is usually larger than the WebM, and that is not a wrong setting: VP9 compresses 30 to 50% better than H.264 at equal quality. On one measured clip, 44 MB of WebM became 64 MB of MP4 at CRF 18 (CRF is the constant-quality setting; lower means better quality). When size matters more than detail, CRF 23 gives roughly half the size. The check after conversion matters more than the conversion. FFmpeg can exit with code 0 and still leave a truncated file, for example when the disk fills or the source is corrupt halfway through. After every conversion we read the result with `ffprobe` and compare its duration with the original; a difference above 0.5 seconds is reported as a failure, and in that case the original is never deleted. **Step 3: produce the three Performance Max formats and a script built for silent viewing** Performance Max video comes in landscape 16:9 (1920×1080), square 1:1 (1080×1080) and vertical 9:16 (1080×1920). We deliver all three for every concept, each at least 10 seconds and ideally around 15, as MP4. Each format is recomposed, not cropped blindly from another. The script follows hook, body, call to action: the first 3 seconds must stop the scroll, a new shot every 2 to 3 seconds keeps momentum, and the end states the offer. Captions are written to make sense with the sound off, at most 5 to 7 words per frame, large and high contrast, kept clear of the bottom strip where the Shorts and Reels interface sits. Prices and discounts on screen come from the store's live data (old price struck through, new price, percentage off) and are never written by the model. Rendering traps produced broken files without an error: a crop whose size changes over time gave a 0-byte file, and two formats rendered in parallel collided on temporary files and produced a 48-byte file. Formats now render one after another, and the renderer checks its output and fails loudly. **Step 4: upload to the brand's own YouTube channel, then attach in two calls** Each brand has its own YouTube channel, all Brand Accounts under one Google account and one OAuth app. Consent is given once per channel, and the refresh token can only upload to the channel chosen at consent, so we confirm the channel right after by listing it with the read-only scope. In Testing mode, refresh tokens for a sensitive scope expire after about 7 days: fine for one batch, not for a daily job. Upload quota limits volume. One YouTube upload costs 1,600 units of a 10,000-unit daily allowance, which is about 6 uploads a day, and the allowance resets at midnight Pacific time. Attaching a YouTube video to Performance Max is two API calls: create a YouTube video asset from the video ID, then link the asset to the asset group with field type `YOUTUBE_VIDEO` (not `VIDEO`, which is rejected as incompatible). The asset group must already have a final URL, or the link fails with `FINAL_URL_REQUIRED`. On a new asset group still under review, linking in a batch fails with `CONCURRENT_MODIFICATION`, so we link one video at a time with retries. We attach roughly 5 to 9 videos per asset group, chosen from creatives that already performed in paid social. For a library of 363 videos, a daily job ranks them by the product's stock and recent sales, skips products with no stock, takes at most 3 per product and uploads 5 a day. **The traps we hit and how we catch them now** **The wrong channel.** Videos attached to one brand's asset group were hosted on a generic upload channel instead of the brand's channel. Nothing failed and the ads ran. We found it by reading the video ID of every attached asset and asking YouTube which channel hosts it. We re-uploaded, attached the new copies and removed the old links. **Removed links in counts.** Queries over asset-group assets also return removed links, so videos looked doubled. Filter on `ENABLED`. **Videos deleted from YouTube.** In one channel audit, 8 videos in our upload register no longer existed on YouTube, so ad assets pointing at them may be broken. The register (drive file ID to YouTube ID) is what we audit against. **A time cut-off that skips failures.** A separate importer collected clip links shared in a group chat and fetched only links newer than the last clip that arrived. Any link that failed once was skipped for good, because later links moved the cut-off past it. After 16 days, 8 of 59 links had never arrived: 4 were never tried and 4 had failed once, and 3 of those worked on a fresh try. Each link now has its own row with a state; retries read the state, not a date, and a 10-day window, longer than the transfer service's 7-day expiry, ends retries on dead links, which are reported. **A job that ran but did nothing.** After the daily upload moved to a server, it ran on schedule for two days and uploaded nothing: one script still pointed at a credentials file on a laptop, and the channel tokens were missing from the server's environment. After any deploy we now run the pipeline once for real and check the register. **Captions that repeat the speaker.** A model captioning a talking-head creator video can copy the creator's spoken self-introduction into the brand's voice; we found one case in about 90 captions, and a check on the output now rejects it. **Where we were wrong** Two parts we built are not in production. The montage styles that use real demo footage from suppliers are blocked: the clips carry burned-in text in other languages, and the OCR we used to reject them was unreliable, so those styles fall back to photos. Generating a clip from one product photo is wired and tested against a hosted model but waits for billing to be enabled, and its output is about 5 seconds (81 frames at 16 frames per second, 480p). That is under the 10-second minimum, so such a clip can only be one shot inside a longer edit. We also trusted success signals too much: the scheduler ran and the ads served while videos sat on the wrong channel or were never uploaded. We stopped chasing Google's Ad Strength label: it is a guidance score, not a performance measure, and rewriting the assets of a working campaign to raise it resets learning. **Limits and checklist** This method gets correct video into the right asset group on the right channel. It does not show that the videos sell: we have no controlled test of what video adds, and popular figures about silent viewing are industry claims, not our measurements. **Checklist.** Clip of 11 seconds or more; H.264 and AAC MP4, duration checked; three formats, recomposed; captions readable on mute; prices from live data; brand's own channel, confirmed; final URL set; `YOUTUBE_VIDEO`, linked one at a time; count `ENABLED` only; hosting channel checked; one register row per file. ### Scheduling social media posts through an API: why "scheduled" does not mean "published" A scheduling API that answers HTTP 200 has accepted the post, not published it; publishing happens later and can fail separately on each network. In the period we measured, our poster logged success on every run while all 17 Facebook posts and 12 Instagram posts failed. We now read the final status per network after the publish time, expire every cached list of connected accounts, and check generated captions in code, on the text that actually leaves. **What "scheduled" and "published" mean in a social posting API** When you schedule a social media post through an API, a 200 response means the scheduler accepted the request, not that any network published the post. Publishing happens at the scheduled time, separately for each network (TikTok, Instagram, Facebook, YouTube), and each one can fail without changing the response you logged. To know whether a post went out, ask the scheduler for the final status of each network after the publish time. We publish short vertical videos for several store brands through a third-party scheduling service: one API request carries one video, one caption and a list of networks, and the service publishes to each network at the chosen time. For some pages we also post directly through Meta's Graph API. The difference between accepted and published applies to both paths. **How our social posting pipeline works** **1. Source.** Edited clips come from a shared drive, one folder per brand. A vision model watches each clip and returns its quality, whether it is on brand, whether it carries another brand's watermark, and a draft caption. Every verdict, rejections included, is stored with the file ID as key, so a clip is never downloaded or judged twice. **2. Filter the format.** Only real vertical video passes: height greater than width and width divided by height at most 0.65, which is 9:16. Square, 4:5 and landscape clips get bars added by the platforms. A second check asks the model whether bars are burned into the picture, for example a landscape clip placed on a vertical canvas. Duplicates are matched on normalised name plus duration; one clip had been uploaded 8 times under slightly different names. **3. Queue.** A daily refill job tops up each brand's queue when it falls below a minimum (first 3 items, refilled to 5; now 4, refilled to 7). A brand with nothing new in its folder costs nothing and resumes on its own when content appears. **4. Post.** A daily job drains the queue round robin and schedules the day's posts at fixed hours. The refill and the poster share one lock, because a picker and a poster writing the queue at the same time overwrote the "posted" flags. A separate register of what was posted per brand is the source of truth when the queue is damaged. **5. Verify.** After the publish time, the status of every network is read back. This step was missing at first, and the next section shows what that cost. **The failure: HTTP 200 on every run, nothing on two networks** In the period we measured, the poster's log showed HTTP 200 on every run. The status per network said something else: TikTok 17 of 17 published, YouTube 11 of 11 published, Facebook 17 of 17 failed, Instagram 12 failed. The response we logged belonged to the scheduling request; publishing happened later, per network, and its result never reached our log. The cause underneath was a cache. Our list of brands and their connected networks was a local file 2 months old, read with no expiry, so the poster kept sending Facebook and Instagram to brands whose connections had lapsed. The same stale cache caused a second kind of damage: a profile renamed for a different business kept its internal ID, and content meant for the old business was published on the renamed profile. The fix has three parts. The connected-networks cache now expires after 12 hours, falling back to the old copy only when the API cannot be reached. The final status of each network is read back from the scheduler; that request needs full ISO date-times, because a compact date format returns 400. And when a post to all networks fails, it is retried without YouTube, so one network rejecting a video does not take the others down. The request that led us here was "post more often". Had we only raised the cadence, we would have multiplied failed posts and reported success. **Captions: the model writes them, code checks them** A caption generated from a video can repeat what the person in the video says. On a talking-head clip, a creator introduced themselves by name; the model wrote the caption as if the brand account were that person and turned the name into a meaningless common noun. It went out on three networks, and on YouTube the caption becomes the title, cut mid-word. A scan of the queue found one such case in about 90 captions. Captions in the first person as an anonymous customer ("I tried it") remain allowed; self-introduction by name does not. The rule now lives in two places: in the prompt, and in a check on the output that asks for one rewrite and rejects the clip if the rewrite fails too. Writing that check had its own trap. A case-insensitive regular expression turned the "capitalised name" pattern into "any word", and the first scan reported 23 broken captions where there was one. Test a check on good and bad examples before believing it. Brand and product names are corrected deterministically, after the model and before the queue, because the model kept misspelling them: a product line name went out with one wrong letter on three networks, and a third spelling was waiting in the queue. Known misspellings are replaced exactly; a fuzzy match at similarity 0.82 or above handles new ones, only on capitalised words, because in a dry run ordinary lower-case words came close enough to brand names to be "corrected". The list of correct names comes from product titles in the store, not from memory. A corrected or rejected caption must also be fixed in the stored verdict cache, or it returns at the next rebuild. **Correct the text that actually leaves** The same lesson came from another text pipeline of ours, drafted customer-service replies, and it applies to captions. A deterministic corrector ran once, at the entry of a chain of checks, while at least five later steps rewrote the text, some by regenerating it with a model. The same error came back three times by three different paths, and each time we thought it was fixed. The diagnosis came from running the corrector by hand on the final output: it fixed the text, so the text had never passed through it. Deterministic correctors for names, punctuation and fixed forms belong at the common exit, on the text that is published. If they are idempotent, run them at entry and at exit. A verifier that rejects a regeneration is not a corrector: it does not clean the first draft. **Schedules, time zones, hidden caps and platform rules** **Time zone.** The server clock was one hour behind local time, so "now" labelled with the local zone was an hour in the past and the scheduler rejected near-future slots as being in the past. Times are now computed in an explicit zone, independent of the system clock. **Hidden cap.** The poster takes the first N entries of a list of posting hours, so posts per day can never exceed the number of hours listed: with 13, 15, 18 and 20, the maximum is 4 whatever the slot setting says. Added hours must be later than 10, the hour the daily job runs, or the day's schedule empties and slides to tomorrow. We raised the cadence from 1, 2 and 3 posts a day (other days, Friday, Saturday) to 2, 3 and 4, from 10 to 17 posts a week across 15 brands in rotation. **Two channels for one brand.** One brand has two YouTube channels; the scheduler publishes to one and our management token belongs to the other, so a wrong title cannot be fixed through the API. A token check that answers "write OK" confirms a channel, not the channel you meant. **The direct path.** Through the Graph API, a Facebook page post can be scheduled between 10 minutes and 6 months ahead. Our Instagram path cannot schedule and publishes immediately; it needs an image at a public URL and works in two steps: create a media container, wait until it is finished, then publish. Only 8 of our 22 pages had an Instagram account linked. A post to a public page cannot be undone, so the direct tool defaults to a dry run. **Where we were wrong, and what this does not guarantee** We treated an accepted request as a delivered post, kept a cache of connected accounts with no expiry, and trusted a prompt to prevent a mistake that only a check on the output catches. Each of these looked healthy in our own logs. Reading the final status proves a post was published, not that it worked. Our performance analysis over 9,404 past TikTok posts found Saturday at 2.6 times the median views of other days, with offer and urgency content on top; that is a correlation in our history, not a test, and the hour pattern may partly be a time-zone artefact. **Checklist.** Log the scheduling response and, separately, the final status per network; give every cache of connected accounts an expiry; filter clips to real 9:16 and reject burned-in bars; deduplicate by name and duration; share one lock between anything that writes the queue; correct names in code at the exit; check generated captions for impersonation; compute times in an explicit zone; dry-run anything that posts to a public page. ### How to audit a product landing page for conversion, step by step A conversion audit of a product landing page checks, in a fixed order, whether a visitor on a phone can see the offer, the price, the buy button and the reasons to trust the shop, and whether the page loads fast enough to be seen at all. The output is a list of hypotheses ranked by likely impact, not a proven lift: only an A/B test can say whether a fix raised conversion. **What a landing page conversion audit answers** A landing page conversion audit answers one question: why does a product page that receives traffic fail to turn that traffic into orders? Conversion rate optimisation (CRO) is the practice of finding and removing the obstacles between a visitor arriving and a visitor buying. A landing page is the page an ad or a search result sends people to, usually a product page or a dedicated offer page. We audit a page before sending more advertising budget to it, because paying for more visits to a weak page buys more people who leave. The audit reads only public HTML and changes nothing on the store. The audit has three layers. The first is a scored checklist computed from the page source. The second is a visual pass at phone width, because the source cannot tell whether a button is visible without scrolling. The third is a consistency check of the page's own words, numbers and structured data. **Step 1: score the conversion essentials from the page source** The first layer fetches the page with a mobile browser identity and checks for the elements that make people buy. **1. Offer.** A clear product heading, a visible price, a buy button such as „add to cart” or „order now”, and the promotion stated in words when there is one. **2. Trust.** At least two trust signals among free shipping, returns, a guarantee, cash on delivery and secure checkout. For shops where many customers pay the courier at the door, stating cash on delivery is one of the strongest levers, so our scorer weights it heavily. **3. Reviews.** Visible reviews and an `AggregateRating` block in the structured data, which is what lets a search engine show star ratings next to the result. **4. Objections.** A frequently asked questions section covering delivery, returns and the product's main doubts, with `FAQPage` structured data. **5. Urgency, used with restraint.** Honest scarcity, such as limited stock or an offer end date. Invented scarcity damages trust. **6. Structured data and mobile basics.** `Product` and `Offer` JSON-LD with price and availability, a meta description, a viewport meta tag, and lazy loading on at least half of the images below the fold. Our scorer turns these into a score out of 100 by weighting each signal. On a product page the buy button weighs most (18), then reviews (14), cash on delivery (12) and a visible price (10); free shipping and returns weigh 8 each; urgency, at least 3 images, a description of at least 120 words and an email capture weigh 6 each; the viewport tag weighs 4. The weights encode our judgement of impact, not a measured law, and another shop should adjust them. **Step 2: look at the page at phone width** A source-based scorer checks presence, not prominence. A buy button can exist in the HTML and still sit below the first screen, and the scorer will mark it as present. So the second layer opens the page in a real browser, resizes it to a phone width, takes a screenshot and asks one question: are the offer, the price, the buy button and a trust signal visible without scrolling? A useful test from the general CRO playbook is whether a visitor can understand what the product is and why it matters within 5 seconds. We also check message match: the headline on the landing page should repeat the promise of the ad that sent the visitor, because a mismatch reads as a wrong click. Speed belongs to this layer. Core Web Vitals are Google's page experience measures: Largest Contentful Paint (how long the main content takes to appear), Cumulative Layout Shift (how much the layout jumps while loading) and Total Blocking Time. On mobile, a slow page loses visitors before any other element can matter, which is why speed comes first in our fix order. The hosted PageSpeed API was not available to us on the key we had, so we run Lighthouse directly in a browser instead. **Step 3: check the page against itself** The third layer reads the page as a sceptical customer would. Every number in the copy should agree with every other number on the same page. On one of our kit landing pages the headline claimed the product lasted two to three times longer than ordinary nail polish, while the comparison table lower on the same page gave three weeks or more against two to three days. The copy undersold the product by its own evidence. We now check every numerical claim in the copy against the tables and specifications on the same page. Reviews must be real and the average must be computed from the actual rating distribution, never typed in. On a custom page built outside the product template, the review widget did not render at all, so we fetched the real reviews through the review app's API and rendered them as static text, with the average taken from the distribution. Find where the visible text actually comes from before editing it. On one product, a corrected FAQ in the page template never appeared live, because the visible FAQ came from the product description field, which the shop also repeated in its meta tags and structured data. The fix was to edit the description, not the template. **Step 4: rank the fixes and write them as hypotheses** We fix in impact order: speed, then the offer and buy button on the first screen, then trust, then reviews and structured data, then the FAQ, then urgency. The report is split into quick wins, larger changes, test ideas and copy alternatives, with two or three alternatives for the headline and the button text. Each fix is written as a hypothesis, using the structure „because we observed X, we believe change Y will cause outcome Z for audience W, and we will know when metric M moves”. A hypothesis names the metric in advance, which keeps a later reading honest. One audit of a collection page scored 64 out of 100 and was missing three things: a mention of cash on delivery, a visible returns or guarantee statement, and an email capture. Those are three candidate improvements, not three proven wins. **The traps we hit, and how we catch them now** **1. A layout bug that only one browser shows.** Product titles on a page built with a visual page builder overflowed onto the price, but only on iPhone Safari. The builder's „number of lines: 0” option emitted an invalid `-webkit-line-clamp: 0`, which Chrome, the builder's editor and a desktop web view all quietly ignored. We lost time trying to reproduce it in those three environments. What solved it was counting the value across the live HTML: 42 text blocks had the variable unset and were healthy, and exactly 6 had the value 0 and were exactly the 6 broken ones. When something breaks only on a phone, we now histogram the mobile-specific style values on the live page and look for the rare one. **2. A stale page after a fix.** After publishing a theme change, the browser and the CDN can serve the old page for a few minutes. We confirm the change by reading the theme file back through the store's admin API, not by reloading the page. **3. A tool's blind spot read as a defect.** Email popups are usually loaded by JavaScript, so they are absent from the static HTML. Our scorer looks for the popup's loader script as well, and reports „not found in static HTML, check visually” instead of „missing”. **4. Paid traffic to a page that cannot sell.** Before the audit, check that the product is in stock or can be back-ordered. „Zero stock in the shop” does not always mean „cannot sell”, so we read stock from the physical warehouse, not only from the shop's counter. Our first lists of „ads on out-of-stock products” were false positives for exactly that reason. **Limits: an audit produces hypotheses, not results** A conversion audit checks the presence and visibility of known conversion elements. It does not measure whether adding them raises conversion on a given page, for a given audience. The weights in the score are our judgement. We have not measured the effect of each fix on our own pages, and we do not claim a conversion lift from any audit here. The way to turn a hypothesis into a result is an A/B test: two versions of the page shown to randomly split visitors, one variable changed, the sample size fixed in advance and the test not stopped early when the numbers look good. Sample sizes are larger than most people expect. At a 3% baseline conversion rate, detecting a 10% relative lift needs about 47,000 visitors per variant; at a 10% baseline and a 50% lift it needs about 550. A page with little traffic cannot validate small changes, and an audit is then a list of reasonable defaults rather than tested improvements. **Checklist** Score the source. Look at the first screen at phone width and measure Core Web Vitals. Check every number in the copy against the page's own tables. Find where the visible text comes from before editing it. Verify a change through the admin API, not a reload. Rank fixes as speed, first screen, trust, reviews, FAQ, urgency. Write each fix as a hypothesis with a named metric, and test it before calling it a win. ### How to monitor competitor prices for commodity products To monitor competitor prices, keep a list that maps each of your products to the same product on competitor sites, read the price from the page's structured data every night, store every reading in an append-only history, and compare only fresh, plausible prices against yours. Matching the right product and keeping the feed alive are harder than reading the price. A price change based on the comparison is a decision to test, not a proven gain. **What competitor price monitoring is for** Competitor price monitoring is the regular, automated reading of the prices other shops charge for the same products you sell, so that you know when you are undercut and where you have room to charge more. It matters most for commodity products: generic goods such as home and garden items, where shoppers compare the identical item across several retailers and choose largely on price. For products that are not the same item as the competitor's, a direct price comparison says much less. A monitor answers three questions per product: what is the cheapest live competitor price, how far is ours from it, and is that gap a problem or an opportunity. We built two versions. One is a small standalone watchlist for ad hoc checks. The other feeds a nightly verdict engine for a home-and-garden store and produces a repricing plan. **Step 1: map each product to the same product elsewhere** The mapping between your product and the competitor's product page is the foundation. Every later number depends on it. A mapping is a row with your product, the competitor's product URL and the last price read. When we revisited the home-and-garden store's engine, the mapping was already in good shape: 396 of 479 products (83%) had at least one competitor URL. Of those URLs, 94% pointed directly at retailers' product pages; the other 6% were ad redirect links, which are not product pages and should be discarded. We also tried to build mappings automatically by matching product images against a product database. Recall was 15%: most products were not found, partly because that database did not cover the main marketplace in the niche. We kept image matching only as a supplement for products with no mapping. A human-reviewed URL list beat clever matching by a wide margin. **Step 2: read the price from structured data, not from the visible page** The visible HTML is the most fragile place to read a price. The order we use is: the platform's own product endpoint where one exists (on Shopify, the `/products/{handle}.js` file), then the JSON-LD `Product` block's `offers.price` and `availability`, then the `product:price:amount` or `og:price` meta tags, then an `itemprop="price"` attribute. Most Shopify, WooCommerce and similar shops publish JSON-LD, because search engines use it for rich results. Normalise the number before storing it. In Romanian and many European formats, `1.234,56` means one thousand two hundred and thirty-four point five six, with the dot as a thousands separator and the comma as the decimal mark. A naive parser will reject it or read the wrong value. Strip the currency text first, then decide which symbol is the decimal mark. Give every URL a hard time limit. Our nightly re-scrape stops waiting on any single URL after 18 seconds, because a few hanging DNS lookups were enough to stall a whole run. **Step 3: store every reading, and feed the engine before it runs** Store prices in an append-only history: one new row per reading, never an update of the old one. Append-only history answers „what was the competitor's price last Tuesday?” and lets you see a price drop as a change, not as an overwritten value. Our watchlist flags two events on each check: a competitor price that dropped since the last reading, and a competitor price below ours. Schedule the scrape before whatever consumes it. Our re-scrape runs every night, an hour before the verdict engine, so the engine always reads prices from the same night. Measure the success rate per run and treat it as the health signal. Our re-scrape reads a price for roughly 84% to 87% of mapped URLs; the failures are mostly HTTP 403 bot blocks and dead URLs. A scraper can return HTTP 200 on every page and still extract nothing useful, so the success count must be the number of prices extracted, not the number of pages fetched. **Step 4: compare only fresh and plausible prices** A verdict per product compares our live price with the cheapest and the median live competitor price and labels the product as too expensive, OK, or having headroom (room to raise the price). Two filters keep bad data out of the verdict. **1. Freshness.** Only competitor prices read within the last 3 days count. A stale price is worse than no price, because it looks like data. **2. Plausibility.** A competitor price below 35% or above 280% of ours is ignored. Prices that far away almost always mean the mapping points at a different product, a different pack size or an accessory. A small tolerance, 5% in our setup, separates „roughly equal” from „more expensive”, so a few cents of difference do not trigger a change. **Step 5: turn the verdict into a cautious repricing plan** Price competitiveness alone does not decide a price. Our plan also reads each product's recent sales and its margin after its real advertising cost per order, and makes two asymmetric moves. It raises prices on products that sell well but keep too little after advertising, moving them toward the market price. It lowers prices on slow sellers with plenty of margin, but never below a floor, calculated as `floor = (1 + VAT) × (cost of goods + shipping + advertising cost per order + minimum profit)`, so an advertised sale still pays for itself. Products without stock are skipped. If a product loses money even at the market price, the plan flags it as an advertising problem, not a pricing one. Changing the price cannot fix a cost per order that is too high. Before writing any price, the apply step checks that the live price still equals the price the plan was built on, and skips the product if it has changed. It applies only clear cases, keeps the old price in the plan file so every change is reversible, and runs as a dry run unless explicitly told to write. The first run applied 28 price changes, 25 increases toward the market and 3 decreases where we were above it, and held back 21 products flagged as advertising problems plus the increases that had no live competitor price to anchor them. **Where we were wrong** We first assumed the verdict engine was broken, because 157 products sat on „insufficient data”. The engine was running every night. What had died was its input: the old scraper that fed competitor prices had stopped, and the freshest competitor prices were about 90 days old. The fix was a new feed for the existing mappings, not a new engine. Check the age of the inputs before rebuilding the thing that reads them. We also spent effort on automatic image matching before re-reading the URLs we already had. The URL list gave far better coverage. Our first impact check came about 1.5 days after the changes. All 28 new prices had held, demand was within its usual daily range, and the highest-volume product kept its demand after an 18% increase. But a 15-unit spike on one product turned out to be a single bulk order, and many products that sell less than once a day showed zero sales simply by chance. 1.5 days cannot measure price elasticity. The proper check is after 7 to 14 days, product by product, including the product's position in shopping ads, which can fall when its price rises. **Limits and checklist** A price monitor tells you where you stand; it does not tell you what a price change will do to sales. It cannot see shops that block automated reading, it is only as good as the product mapping, and the plausibility filter only catches gross mismatches, not a slightly different model. We do not claim a proven profit effect from repricing. **Checklist.** Map each product to the same product on competitor sites, and review the mapping by hand. Read prices from the platform endpoint or JSON-LD before the visible page. Normalise number formats. Put a time limit on every URL. Store every reading append-only. Run the scrape before the engine that uses it, and watch the extracted-price count, not the HTTP status. Compare only prices fresher than a few days and within a plausible band. Raise and lower asymmetrically, with a floor that covers advertising. Verify the live price before writing, keep the old price, and re-check after one to two weeks. ## Research 51 articles. Every one states a measured finding, gives the figures behind it and names its limitations. Each has a plain-text mirror at the Markdown address below, is licensed CC BY 4.0, and may be quoted with attribution. - Index of all of them, in one request: https://goingstellar.ro/research/index.md - Atom feed: https://goingstellar.ro/research/feed.xml (Romanian: https://goingstellar.ro/ro/cercetare/feed.xml) - Every article also exists in Romanian at the same path under /ro/cercetare/. - [Should an AI-made YouTube channel for kids make songs or cartoons? Made for Kids rules, COPPA and Google's under-18 clause](https://goingstellar.ro/research/ai-youtube-channel-for-kids-songs-vs-cartoons/) — published 2026-10-19: Songs are the safer bet: made-for-kids videos lose personalised ads, end screens and notifications, and songs can also earn on audio streaming. Google's generative AI terms bar use in services aimed at or likely used by under-18s, YouTube penalises templated and hard-to-follow videos, and the audience label is the creator's legal responsibility, as Disney's $10 million penalty showed. Markdown: https://goingstellar.ro/research/ai-youtube-channel-for-kids-songs-vs-cartoons.md - [How do you make money from AI-generated music in 2026: YouTube, distributors, Spotify and collecting societies](https://goingstellar.ro/research/how-to-monetize-ai-generated-music-2026/) — published 2026-10-19: AI music can earn on YouTube, mostly from long compilations, as long as each video is disclosed as synthetic and is not mass-produced. Outside YouTube the routes are narrower: some distributors accept fully AI tracks but keep them out of Content ID, Spotify and Deezer limit recommendations, and collecting societies register only works with human authorship. The YouTube entry bar for new channels doubles on 1 February 2027. Markdown: https://goingstellar.ro/research/how-to-monetize-ai-generated-music-2026.md - [Can Google Lyria 3 sing in Romanian and play Romanian genres? What we measured through the Gemini API](https://goingstellar.ro/research/google-lyria-3-romanian-music-test/) — published 2026-10-19: Romanian is not on Google's list of supported vocal languages for Lyria 3, but in our tests it worked: about 85% of the words we sent came back as written, diacritics were pronounced correctly, and the intonation was odd in places. Functional instrumentals such as lofi, sleep and ambient worked every time, while Romanian dance names like sârbă did not: the model needs the rhythm described in musical terms. The list price of $0.04 or $0.08 per song understates the real cost of a usable vocal track, because there is no iterative editing. Markdown: https://goingstellar.ro/research/google-lyria-3-romanian-music-test.md - [How to process your own vocal over an AI instrumental with free Python tools on a laptop without a GPU](https://goingstellar.ro/research/own-vocals-over-ai-instrumental-python-cpu/) — published 2026-10-19: Clean the recording first, then correct pitch, then shape tone and dynamics, then add space, and master last. Each step has an open-source Python tool that runs on a CPU: DeepFilterNet for noise, a BS-RoFormer model for room echo, torchcrepe with pyworld for pitch correction that keeps your timbre, pedalboard for effects and Matchering for reference mastering. Processing fixes wrong notes, not timing or diction, and singing the vocal yourself changes who owns the result and what YouTube asks you to disclose. Markdown: https://goingstellar.ro/research/own-vocals-over-ai-instrumental-python-cpu.md - [Which characters can you legally use in an AI video channel on YouTube from Europe?](https://goingstellar.ro/research/public-domain-characters-ai-video-youtube-europe/) — published 2026-10-19: Characters whose source works are out of copyright in both the EU and the US: Dracula, the Greek and Norse myths, Frankenstein's creature, the Grimm and Andersen tales, Pinocchio, Alice, Oz, Sherlock Holmes and similar figures, plus SCP Foundation material under its CC BY-SA licence. The source text is free; the famous film design, modern translations and registered trade marks often are not. The 2023-24 format of AI slideshows built on studio characters fails on both fronts: YouTube's monetisation rules and the studios' lawsuits against AI image companies. Markdown: https://goingstellar.ro/research/public-domain-characters-ai-video-youtube-europe.md - [How do you get a D-U-N-S number for a Romanian SRL, and why do Apple and Google ask for one?](https://goingstellar.ro/research/duns-number-romania-srl-apple-google-play/) — published 2026-10-19: A D-U-N-S number is free, and Apple and Google both require one before a company can open an organisation developer account. In Romania, Dun & Bradstreet is represented by its partner ICAP CRIF, but a company can also request the number through Apple's lookup tool, where Apple tells you to allow up to 5 business days. The details you enter have to match the company's registered legal name and headquarters address, or verification fails later. Markdown: https://goingstellar.ro/research/duns-number-romania-srl-apple-google-play.md - [Google Play developer account in 2026: personal or organization, what verification asks for, and what the new rules change](https://goingstellar.ro/research/google-play-developer-account-2026-personal-vs-organization/) — published 2026-10-19: A Google Play developer account costs a one-time US$25 and comes in two types. A personal account needs a government ID but must run a closed test with at least 12 testers for 14 consecutive days before production; an organization account skips that test but needs a D-U-N-S number, organization documents and a verified website, and shows its address and phone number on Google Play. A personal account can now be switched to an organization, not the reverse. Markdown: https://goingstellar.ro/research/google-play-developer-account-2026-personal-vs-organization.md - [What sells on the Atlassian Marketplace in 2026, and how do you read the demand signals for a Jira or Confluence app?](https://goingstellar.ro/research/atlassian-marketplace-what-sells-demand-signals-2026/) — published 2026-10-19: Installs are concentrated in integrations, admin toolkits, time tracking, test management and diagrams: the top 50 apps hold more than half of all installs, and the median app has single-digit installs on Jira. Small Forge apps still get in, mostly in reporting gadgets, admin utilities and diagram-as-code, while Atlassian charges no revenue share on Forge revenue up to $1 million per partner. The end of Data Center, on 28 March 2029, is the main engine of new cloud demand. Markdown: https://goingstellar.ro/research/atlassian-marketplace-what-sells-demand-signals-2026.md - [Why do most new Shopify apps fail, and where is there still real demand in 2026?](https://goingstellar.ro/research/why-new-shopify-apps-fail-where-demand-remains-2026/) — published 2026-10-19: Most new Shopify apps never get a single review: in our random sample of 400 listings on 19 October 2026, 62% had none, and 79% of the apps launched in 2026 had none. The shelf is filling faster than merchants arrive, and the reviews that do arrive go to a few apps per niche. The demand that is left is easiest to find where a platform change reset a category, and it can be tested from public data before any code is written. Markdown: https://goingstellar.ro/research/why-new-shopify-apps-fail-where-demand-remains-2026.md - [Who makes print-on-demand products for Romanian stores in 2026, what it costs to start, and which rules apply](https://goingstellar.ro/research/print-on-demand-romania-2026-players-costs-funding-rules/) — published 2026-10-19: Romanian online stores can sell printed products without owning a printer, through EU-based platforms such as Printful, Printify and Gelato, or through Romanian workshops; the public accounts of the local players we checked show steady, modest growth rather than a boom. Owning equipment means prices that dealers mostly give only on request, and the PNRR can no longer fund it: as of 19 October 2026 the open money is regional EU funding with short windows. Printed products still carry the full set of EU labelling and product-safety rules. Markdown: https://goingstellar.ro/research/print-on-demand-romania-2026-players-costs-funding-rules.md - [How do ghost hunting and spirit box apps pass App Store and Google Play review?](https://goingstellar.ro/research/ghost-hunting-spirit-box-apps-app-store-google-play-review/) — published 2026-10-19: Neither Apple nor Google bans paranormal entertainment apps, and neither accepts a 'for entertainment purposes only' line as a defence for fake features. The apps that stay listed read real phone sensors, describe what they actually do, and say plainly that they do not detect spirits. The bigger obstacle on the App Store is guideline 4.3(b): a crowded category has to show a meaningfully different experience. Markdown: https://goingstellar.ro/research/ghost-hunting-spirit-box-apps-app-store-google-play-review.md - [How do you take a deposit on Shopify in 2026: part of the payment now, the rest later](https://goingstellar.ro/research/shopify-deposits-partial-payments-2026/) — published 2026-10-19: Shopify has a native deposit mechanism only for pre-orders and try-before-you-buy, and it works only through an app that schedules and charges the balance itself. Deposits at checkout through payment terms, deposits on draft orders and partial payments in the admin are limited to Shopify Plus. Everything else comes from apps, whose low-rated reviews point to the same weak spots: billing, storefront scripts and the second payment. Markdown: https://goingstellar.ro/research/shopify-deposits-partial-payments-2026.md - [How do Shopify stores use WhatsApp in 2026: Meta's official app, per-message pricing, opt-in and COD confirmation](https://goingstellar.ro/research/whatsapp-shopify-stores-cloud-api-pricing-2026/) — published 2026-10-19: Shopify stores connect to WhatsApp either through Meta's official Cloud API, used by Meta's own app and many third-party apps, or by automating a number linked as a device, the route behind the account bans merchants report. Since July 2025 Meta charges per delivered template: in Romania $0.086 for marketing and $0.029 for utility messages such as order confirmations. Abandoned-cart reminders count as marketing and need marketing consent. Markdown: https://goingstellar.ro/research/whatsapp-shopify-stores-cloud-api-pricing-2026.md - [How do you convert a PDF bank statement into a file QuickBooks can import: QBO, CSV or OFX?](https://goingstellar.ro/research/convert-pdf-bank-statement-to-quickbooks-qbo-csv-ofx/) — published 2026-10-19: QuickBooks imports bank transactions from files, not from PDFs. QuickBooks Online accepts CSV, QBO, QFX and OFX uploads of up to 350 KB and 1,000 lines; QuickBooks Desktop takes only Web Connect .qbo files, which Intuit ties to a bank with a contracted identifier. Converters fill the gap between the PDF and those formats, at prices from about $3 per statement to $100 a month, and the main risks are misread amounts, duplicate transactions and where the statement files go. Markdown: https://goingstellar.ro/research/convert-pdf-bank-statement-to-quickbooks-qbo-csv-ofx.md - [What do AppSumo and Product Hunt actually bring a small B2B software product in 2026?](https://goingstellar.ro/research/appsumo-product-hunt-small-b2b-software-2026/) — published 2026-10-19: Launch cash, reviews, traffic and a few links, but not recurring revenue on their own. In published AppSumo campaigns founders kept roughly a quarter to a third of gross sales after AppSumo's share and refunds, and took on lifetime support. A Product Hunt launch lasts one day on a moderator-curated homepage; the best-documented small launch we found produced 5 paying users. Markdown: https://goingstellar.ro/research/appsumo-product-hunt-small-b2b-software-2026.md - [What can an AI chatbot really handle for an online store in 2026, and where does it go wrong?](https://goingstellar.ro/research/ai-chatbots-for-online-stores-what-they-can-automate-2026/) — published 2026-10-19: An AI chatbot on a store can reliably answer product, shipping and policy questions from the store's own content, and it can answer "where is my order" only when it is connected to live order and courier data. It goes wrong on refunds, exceptions and policies it paraphrases, and a 2024 tribunal ruling held a company liable for what its chatbot told a customer. Vendors price per resolution, per conversation or per seat, and they do not define a resolution the same way. Since 2 August 2026, EU rules require that people are told they are talking to an AI. Markdown: https://goingstellar.ro/research/ai-chatbots-for-online-stores-what-they-can-automate-2026.md - [How do you sell configurable, high-priced products on Shopify by request for quote: native draft orders, hidden prices and RFQ apps](https://goingstellar.ro/research/shopify-request-for-quote-configurable-products-2026/) — published 2026-10-19: Shopify has no storefront request-for-quote feature, but every plan with B2B can make a company's checkout end in a draft order that staff review, reprice and send back as an invoice with a checkout link. Hiding prices is the weak point: a theme can hide them, but Shopify's public product endpoints and the Storefront API still return them, and Google's product feed requires a price that matches the page. RFQ apps add the form, the hidden price and the quote-to-order step; their low-star reviews are mostly about prices still showing and priced options lost from the quote. Markdown: https://goingstellar.ro/research/shopify-request-for-quote-configurable-products-2026.md - [How do Shopify stores in Eastern Europe handle cash on delivery: COD forms, fees and order confirmation](https://goingstellar.ro/research/cash-on-delivery-shopify-eastern-europe/) — published 2026-10-11: Shopify treats cash on delivery as a manual payment method: the order is created unpaid, no money moves through Shopify, and there is no built-in fee or confirmation step. Stores that sell mostly on COD fill those gaps with apps: a COD order form that bypasses checkout, phone confirmation by one-time code, rules that hide COD, and fees added by an app. Refunds for cash orders still happen outside Shopify. Markdown: https://goingstellar.ro/research/cash-on-delivery-shopify-eastern-europe.md - [Can you create YouTube in-stream campaigns through the Google Ads API? What Demand Gen lets you automate](https://goingstellar.ro/research/demand-gen-youtube-in-stream-google-ads-api/) — published 2026-10-11: Classic Video campaigns cannot be created or updated through the Google Ads API; it only reads and reports on them. The documented route to an API-built YouTube in-stream campaign is a Demand Gen campaign whose ad groups select only the in-stream channel. We checked an AI research report on this against Google's API documentation on 11 October 2026 and list what it got right, what it missed and what had changed. Markdown: https://goingstellar.ro/research/demand-gen-youtube-in-stream-google-ads-api.md - [Which EU rules change how online stores sell in 2025–2027? A dated checklist](https://goingstellar.ro/research/eu-rules-online-stores-2025-2027-checklist/) — published 2026-10-11: Between December 2024 and mid-2027, EU law adds product-safety details to every online listing, bans generic green claims, puts a duty on cheap imported parcels, starts the packaging regulation and the battery passport, and phases in national e-invoicing. We started from two AI deep-research reports, kept only the dates we could confirm at the source, and list the ones the reports got wrong. Markdown: https://goingstellar.ro/research/eu-rules-online-stores-2025-2027-checklist.md - [The European Accessibility Act and online stores: what it requires and who is exempt](https://goingstellar.ro/research/european-accessibility-act-online-stores/) — published 2026-10-11: Since 28 June 2025 the European Accessibility Act requires consumer-facing online stores in the EU to be accessible to people with disabilities. Microenterprises that provide services are exempt, but the test is narrower than most summaries say: fewer than 10 persons and a turnover or balance sheet not exceeding 2 million euro. We checked the claims in an AI research report against the directive text and found the threshold misstated. Markdown: https://goingstellar.ro/research/european-accessibility-act-online-stores.md - [Do feature-request votes predict app installs? What a public backlog told us](https://goingstellar.ro/research/feature-request-votes-do-not-predict-installs/) — published 2026-10-11: Votes on a platform's public feature-request tracker did not predict installs. Across the ten ideas that passed a first filter, 4,339 votes had turned into about 440 installs spread over about 40 competing apps: roughly one install per hundred votes. The ticket's status carried more signal than its vote count, and the status was the part most often misread. Markdown: https://goingstellar.ro/research/feature-request-votes-do-not-predict-installs.md - [How small B2B software companies actually got their first paying customers](https://goingstellar.ro/research/first-paying-customers-small-b2b-software/) — published 2026-10-11: We checked eleven founder accounts at their sources. The first paying customers came from people the founder already knew, from problem threads the founder answered by hand, or from one launch inside a concentrated community. Only one came from a marketplace listing, and that founder had nearly a decade of experience in the ecosystem. Markdown: https://goingstellar.ro/research/first-paying-customers-small-b2b-software.md - [What is different about Google Ads for an online store in Romania, and what does Google's documentation actually say?](https://goingstellar.ro/research/google-ads-ecommerce-romania-documentation-check/) — published 2026-10-11: We took an AI deep-research report on Google Ads for Romanian e-commerce and checked its claims at Google's own help pages on 11 October 2026. The account mechanics are the same as anywhere else; what differs is European: Shopping ads go through a Comparison Shopping Service, EEA consent signals need two extra parameters, and several 2026 deadlines in the report have already passed. Markdown: https://goingstellar.ro/research/google-ads-ecommerce-romania-documentation-check.md - [Does the app name or the tagline decide marketplace search rank?](https://goingstellar.ro/research/marketplace-search-rank-follows-the-app-name/) — published 2026-10-11: On the Atlassian Marketplace, our listings ranked in the top 20 for a query when the app name contained it, and below 170 when only the tagline did. Renaming moved three listings up within hours, and lengthening a name that already matched cost one listing fourteen places. Rank followed the name. Markdown: https://goingstellar.ro/research/marketplace-search-rank-follows-the-app-name.md - [Do app marketplaces bring customers by themselves? What we learned publishing on four with no audience](https://goingstellar.ro/research/marketplaces-dont-bring-customers/) — published 2026-10-11: They do not. We submitted apps to Shopify, Google Workspace, Atlassian and monday.com without an audience of our own. Listings went live, people installed some of them, and paying customers stayed close to zero. Each marketplace failed in a different place, and the platforms' own rules explain part of it. Markdown: https://goingstellar.ro/research/marketplaces-dont-bring-customers.md - [The EU Omnibus price-reduction rule: the 30-day lowest price, explained for online stores](https://goingstellar.ro/research/omnibus-30-day-lowest-price-rule/) — published 2026-10-11: When an online store in the EU announces a price reduction, it must show the lowest price it charged for that product in at least the 30 days before the reduction, and any percentage must be calculated from that price. We checked an AI research report against Article 6a of Directive 98/6/EC, the European Commission's guidance and the 2024 Aldi Süd judgment, and kept only what a merchant needs. Markdown: https://goingstellar.ro/research/omnibus-30-day-lowest-price-rule.md - [Where to get street-level postal-code data for Romania, and how reliable it is](https://goingstellar.ro/research/romania-street-postcode-data/) — published 2026-10-11: The only official bulk file of Romanian postcodes with street ranges is Poșta Română's 2016 workbook on the national open-data portal, reusable commercially under OGL-ROU-1.0. It covers streets only in Bucharest and 47 large localities; everywhere else the code belongs to the whole locality. Newer-looking free sources we checked either lack postcodes, lack streets, or repackage the same 2016 file. Markdown: https://goingstellar.ro/research/romania-street-postcode-data.md - [The most common mistakes in Romanian delivery addresses, and how to correct them](https://goingstellar.ro/research/romanian-address-mistakes/) — published 2026-10-11: Most broken Romanian addresses fail on five things: a postcode that does not match the locality, a village written without its county, a street type fused into the street name, two incompatible versions of ș and ț, and block details lost in free text. We checked each rule against Poșta Română's own data and the UPU addressing sheet, and found that several popular ‘fixes’ are wrong. Markdown: https://goingstellar.ro/research/romanian-address-mistakes.md - [How does Shopify App Store search rank apps? What is documented and what is folklore](https://goingstellar.ro/research/shopify-app-store-search-ranking-documented-vs-folklore/) — published 2026-10-11: Shopify does not publish its App Store ranking formula. It documents a handful of things: term matching against your listing, behaviour after a search, a boost for Built for Shopify apps, and a statement that positive reviews lift an app in search. Most of the rest of what circulates as ranking advice is inference, and some of it breaks Shopify's own listing rules. Markdown: https://goingstellar.ro/research/shopify-app-store-search-ranking-documented-vs-folklore.md - [Shopify B2B in 2026: what the native features cover and when a wholesale app is still needed](https://goingstellar.ro/research/shopify-b2b-native-vs-wholesale-apps-2026/) — published 2026-10-11: Since 2 April 2026, Shopify's own B2B tools are on the Basic, Grow and Advanced plans, not only Plus: company profiles, up to three market catalogs, volume pricing, quantity rules and net payment terms. Plus keeps unlimited catalogs, catalogs assigned to individual companies, deposits and partial payments. Wholesale apps still matter mainly for per-customer pricing below Plus, registration flows and themes or apps that native B2B does not support. Markdown: https://goingstellar.ro/research/shopify-b2b-native-vs-wholesale-apps-2026.md - [Which Shopify platform changes and deadlines open or close app opportunities in 2026?](https://goingstellar.ro/research/shopify-platform-changes-deadlines-2026/) — published 2026-10-11: In 2025–2026 Shopify moved its AI-agent endpoints to the Universal Commerce Protocol, gave B2B and customer accounts new surfaces, and set hard end dates for Shopify Scripts, script tags and older catalog and cart APIs. We checked every date against Shopify's developer changelog and Help Center, and list what the AI research report we started from got wrong. Markdown: https://goingstellar.ro/research/shopify-platform-changes-deadlines-2026.md - [Returns and exchanges on Shopify in 2026: what the platform does natively and where apps fill gaps](https://goingstellar.ro/research/shopify-returns-native-vs-apps-2026/) — published 2026-10-11: Shopify's native returns now cover more than most comparison articles admit: return rules, self-serve return requests, refunds to store credit and a report of returns by reason. The real gaps are narrower: customers cannot request an exchange themselves, and return labels inside Shopify exist only for US domestic orders. Markdown: https://goingstellar.ro/research/shopify-returns-native-vs-apps-2026.md - [Three ways a browser shows you a page no visitor gets](https://goingstellar.ro/research/three-ways-a-browser-shows-a-page-no-visitor-gets/) — published 2026-08-31: Three defects on this site passed every screenshot and failed every measurement: headless Chrome refusing a phone-width layout, lazy loading that never fires inside a sideways-scrolling strip, and a scroll-driven animation that hid content permanently. A fourth, in CSS grid, pushed a footer column off the screen at phone width. All four were found by measuring, and the numbers below are from this site. Markdown: https://goingstellar.ro/research/three-ways-a-browser-shows-a-page-no-visitor-gets.md - [Four corrections before our market scanner meant anything](https://goingstellar.ro/research/four-corrections-market-scanner/) — published 2026-08-30: A category's lifetime review count says how big it once was, not whether anyone is arriving now. Here is what we had to fix before the output was usable, including the correction that changed our strategy. Markdown: https://goingstellar.ro/research/four-corrections-market-scanner.md - [The data failures that cost most never raised an alarm](https://goingstellar.ro/research/data-failures-that-cost-most-never-raised-an-alarm/) — published 2026-08-28: Our costliest data failures ran for days without an error, an alert, or a broken page. Each kept producing numbers on schedule; only the freshness of the data was wrong. The mechanisms we used to catch them are general; the intervals inside them are ours. Markdown: https://goingstellar.ro/research/data-failures-that-cost-most-never-raised-an-alarm.md - [Cost per order is wrong by exactly the confirmation rate](https://goingstellar.ro/research/cost-per-order-confirmation-rate/) — published 2026-08-28: In cash-on-delivery retail, dividing advertising spend by orders placed gives a figure that is smaller than the real one by a factor you can know in advance. We published the optimistic version first and were corrected. Markdown: https://goingstellar.ro/research/cost-per-order-confirmation-rate.md - [The bug on a browser we did not have](https://goingstellar.ro/research/the-bug-on-a-browser-we-did-not-have/) — published 2026-08-27: Product titles overflowed on phones and nowhere else. Three environments that claimed to be the mobile rendering all said the page was fine. Counting one CSS value across the live page found the cause without ever reproducing it. Markdown: https://goingstellar.ro/research/the-bug-on-a-browser-we-did-not-have.md - [A negative search result is not evidence of absence](https://goingstellar.ro/research/negative-search-result-is-not-evidence-of-absence/) — published 2026-08-27: 12 of 20 app listings sat rejected for over two weeks because the reviewers' questions lived in a ticket system, and the mailbox search that would have found the notifications was never validated. Markdown: https://goingstellar.ro/research/negative-search-result-is-not-evidence-of-absence.md - [When a bug wears the fallback's message](https://goingstellar.ro/research/when-a-bug-wears-the-fallback-message/) — published 2026-08-27: A parcel tracker reported missing credentials for every parcel. The credentials were there. A broad except clause kept turning each attempted fix into the same reassuring sentence. Markdown: https://goingstellar.ro/research/when-a-bug-wears-the-fallback-message.md - [The ignore pattern that deleted a live module](https://goingstellar.ro/research/ignore-pattern-deleted-live-module/) — published 2026-08-27: A data directory was ignored without a leading slash, so the pattern also matched a source package deeper in the tree. The package was never committed, a mirror deploy removed it from the server, and the only surviving description of what it did was its test suite. Markdown: https://goingstellar.ro/research/ignore-pattern-deleted-live-module.md - [Two signals, or a person](https://goingstellar.ro/research/two-signals-or-a-person/) — published 2026-08-26: Every plausible rule for automatically repairing a broken delivery address, measured against real orders — and why most of them were rejected. Markdown: https://goingstellar.ro/research/two-signals-or-a-person.md - [Geography predicts refusals. Shipping cost only leaks the answer.](https://goingstellar.ro/research/what-predicts-a-refused-parcel/) — published 2026-08-24: A refusal model trained on roughly 628,000 finished orders scores AUC 0.686 on an honest temporal holdout, and its riskiest fifth of orders holds 39% of all refusals. The first version scored 0.55 because it was reading the answer off a field that only exists after the parcel comes back. Most of the address rules we were confident about did nothing once we put a control group next to them. Markdown: https://goingstellar.ro/research/what-predicts-a-refused-parcel.md - [The right decision, in the wrong order](https://goingstellar.ro/research/right-decision-wrong-order/) — published 2026-08-24: A duplicate-order detector flagged 52 orders as duplicates in one day. Roughly 29 customers still got the same parcel twice, because two automated steps ran in the wrong sequence. Markdown: https://goingstellar.ro/research/right-decision-wrong-order.md - [Zero ads is not an answer](https://goingstellar.ro/research/zero-ads-is-not-an-answer/) — published 2026-08-24: Searched by domain, an ad transparency archive returned zero ads for three subjects. Their owners were running 17 and 13 ads at the time. Here is the check that works, and the recommendation it killed. Markdown: https://goingstellar.ro/research/zero-ads-is-not-an-answer.md - [The test was green, the effect was absent](https://goingstellar.ro/research/test-was-green-effect-was-absent/) — published 2026-08-20: We switched an old automated system off after confirming the new one produced shipping labels correctly. One module was still in shadow mode, and 114 orders over three days shipped without a gift the customer had already paid for. Markdown: https://goingstellar.ro/research/test-was-green-effect-was-absent.md - [A detector that fires on nothing](https://goingstellar.ro/research/detector-that-fires-on-nothing/) — published 2026-08-18: An event trigger set at 2.5 sigma produced 166 events on a device lying still and 151 while something was genuinely happening. Fixing it takes three conditions, not one. Markdown: https://goingstellar.ro/research/detector-that-fires-on-nothing.md - [The CPA spike was a measurement failure, not a performance failure](https://goingstellar.ro/research/cpa-spike-was-a-measurement-failure/) — published 2026-08-12: A store's reported cost per acquisition appeared to jump, so the budget was cut. Reported conversions had fallen from 158 to 44 a day while real orders held at 100 to 110 a day. Performance was fine, the tracking was not, and the budget went back. Markdown: https://goingstellar.ro/research/cpa-spike-was-a-measurement-failure.md - [Brand search ads do not add traffic. They replace it.](https://goingstellar.ro/research/brand-search-ads-cannibalise-organic/) — published 2026-08-04: An account suspension gave us a natural experiment across three online stores. Organic click-through collapsed while brand ads ran and recovered above baseline within a week of them stopping — on two of three. The third is why this is a test, not a rule. Markdown: https://goingstellar.ro/research/brand-search-ads-cannibalise-organic.md - [A target CPA does not know what an order is worth](https://goingstellar.ro/research/target-cpa-does-not-know-what-an-order-is-worth/) — published 2026-07-09: We built one profitability engine that counts only delivered orders, net of VAT, down to the individual SKU. When we compared its verdicts with the target-CPA gate we had been running, the verdicts inverted. The formula below is a planning gate, not realised profit. Markdown: https://goingstellar.ro/research/target-cpa-does-not-know-what-an-order-is-worth.md - [An inactive pixel was not a warning. It was an outage.](https://goingstellar.ro/research/an-inactive-pixel-was-not-a-warning-it-was-an-outage/) — published 2026-06-20: On cash-on-delivery stores running a third-party checkout, the advertising platforms were receiving no purchase event at all. The only visible symptom was a pixel marked inactive, and we read that as a warning for a long time before we read it as an outage. What follows is the mechanism, the fix, and the check anyone can run. Markdown: https://goingstellar.ro/research/an-inactive-pixel-was-not-a-warning-it-was-an-outage.md ### Three ways a browser shows you a page no visitor gets Three defects on this site passed every screenshot and failed every measurement: headless Chrome refusing a phone-width layout, lazy loading that never fires inside a sideways-scrolling strip, and a scroll-driven animation that hid content permanently. A fourth, in CSS grid, pushed a footer column off the screen at phone width. All four were found by measuring, and the numbers below are from this site. **What the browser showed, and what the visitor got** We built this site and checked it the ordinary way. Open the page, take a screenshot at a few widths, look at it. That method passed three defects a visitor would have hit and a measurement caught immediately. A fourth, in the footer, was hidden by a rule we had written ourselves. The three have the same shape. The browser renders something no visitor will ever be served. In two cases the screenshot is honest about the pixels it contains and dishonest about the layout behind them. In the third the content is not on screen at all, so there is nothing to look at and be suspicious about. What follows is what each defect is, how it was measured, and the number the measurement returned. None of it depends on our code. The same checks run against any page. **Headless Chrome will not render a phone-width layout** Chrome in headless mode clamps the layout viewport to about 500 CSS pixels. Ask for a window of 390 pixels and you get a 390-pixel image of a 500-pixel layout. It is a crop, not a rendering. We verified it with a probe page that writes its own window.innerWidth into the document, so the value ends up in the screenshot rather than in a console we might not read. With a window size of 390, the page printed 500. Two things follow. Content cut off by the crop looks exactly like a horizontal overflow bug, so you go hunting for an overflow that does not exist. And every media query below 500 pixels is never exercised at all. That is where the real phone problems live, and the tool never goes there. The fix is device emulation over the debugging protocol, not a window size. Emulation sets the layout viewport itself, so the media queries under the clamp finally run. **Lazy loading never fires inside a horizontally scrolling strip** A marquee is a track wider than its container, moved sideways with a transform, inside overflow: hidden. To the eye it is motion. To the browser it is a very wide element with most of itself outside the box. An image pushed sideways out of that container never enters the viewport. A browser that honours loading="lazy" is therefore right not to load it. The images stay blank, and they stay blank indefinitely, because the transform that moves them does not count as the image approaching the viewport. Measured on this site: 39 of 56 images in one strip never loaded. That was fixed. Later the same mistake was reintroduced in a second strip and caught again at 8 of 28. The second count matters more than the first. We knew the cause, had written it down, and did it again. Knowing the failure mode did not prevent it. The check did. **Where we were wrong: the accessibility rule was the cause** A scroll-driven animation with a fill mode holds its starting state before its range begins. That is the specified behaviour and usually what you want. It becomes a defect when the range never begins: a page too short to scroll, an unusual viewport, anything where the scroll position that would start the animation is unreachable. The element then stays at its starting state forever. If that state is opacity zero, the content is simply gone. Here is the part we got wrong. We had treated the reduced-motion branch as safer by definition: less movement, same page. It is not. A global rule that collapses every animation duration under reduced motion also collapsed the animation that was doing the positioning. A scene that distributed items across a circle stacked all of them at one point instead. The accessibility branch was not a degraded version of the page. It was a different page, and worse. We saw it only because we rendered in both motion regimes rather than one. Had we checked the default regime alone, the version served to people who ask for less motion would have shipped broken. The rule that came out of it is narrow and absolute. A scroll-driven effect may animate transform. Never opacity. Position may depend on scroll. Existence may not. **1fr is not a minimum of zero** In CSS grid, 1fr does not mean "as small as needed". It means minmax(auto, 1fr), and the auto minimum is the content's minimum. A single unbreakable word raises a column's minimum width, and a grid that fits everywhere else stops fitting. One email address did exactly that to an entire footer column at phone width. It pushed the column off the screen. A body rule hiding horizontal overflow kept the page from showing a scrollbar, so the only symptom was content that was not where it should be, with no way to reach it. The lesson is small. Treat a global overflow-hiding rule as something that suppresses evidence, not something that fixes layout. **How to run the same checks** Check the viewport before you trust the screenshot. Load a probe page that writes window.innerWidth and the root element's client width as visible text. If the number is not the one you asked for, the screenshot is a crop, and every conclusion drawn from it is about a layout you did not request. Check images by state, not by eye. After load and after the page has settled, count the images whose complete flag is false. Run that count inside horizontally scrolling containers specifically, because that is where it comes back non-zero while the screenshot looks full. Compare width against width. Put the document's scroll width next to the viewport width at each breakpoint, with any overflow-hiding rule temporarily disabled. Hiding overflow does not remove the overflow. It removes the scrollbar that would have told you. Render in both motion regimes. Once with reduced motion requested and once without, then compare the positions of the elements that animate. Where an element is settled in one regime and not the other, you have found either a defect or the two different pages you are shipping. **Limitations** This describes browser behaviour as of August 2026. The headless clamp in particular is an implementation detail, not a standard. It may change, and the right response is to re-run the probe rather than to trust the number in this article. The counts come from this site's markup. 39 of 56 and 8 of 28 say the failure mode exists and is detectable. They say nothing about how common it is on the web, and nothing about how many images some other strip would fail to load elsewhere. The checks detect absence: an image that did not load, an element that did not settle, a page wider than its viewport. They do not tell you whether the layout is good, whether the copy fits, or whether a real device on a real network behaves the same. They narrow down what a person holding a phone still has to look at. They do not replace that person. And each of these was found because someone thought to measure that particular thing. A defect nobody thought to measure is still there, and this article is not evidence otherwise. ### Four corrections before our market scanner meant anything A category's lifetime review count says how big it once was, not whether anyone is arriving now. Here is what we had to fix before the output was usable, including the correction that changed our strategy. **Stock and flow** A lifetime review count is a stock. It only goes up. It records everyone who ever arrived and said something, including people who have long since left. It is a reasonable measure of how big a category once was. It is not a measure of whether anyone is arriving now. One category we looked at showed a leader with 3,774 reviews. Against a leader of that size, a competent second entrant looks like a winnable position. The same category produced 10 reviews a month across every app in it. Not the leader — the whole category. It was dead, and the stock figure said the opposite. The scanner now derives a monthly rate from the dated review feed and treats the lifetime total as background. Everything downstream runs off the rate: ranking, shortlists, the decision to build. The lifetime number is still printed, because it is useful for reading history, but nothing chooses on it. **A search result is not a category** The platform's search is broad-match and ordered by popularity. Neither property is a defect. It is built for a person who will glance at the results that come up first and recognise what they wanted. It is not built to enumerate a market. A two-word query for a niche tool returned, at the top, a general-purpose utility with over a million ratings. That app is not in the niche. It is in the result set, and once it is there, any average computed over that set — mean rating, mean review count, mean age — is dominated by a member that does not belong. The average is not noisy. It is meaningless. The fix is a membership rule we control: every word of the query must appear in the app's name or description before it counts as part of the category. It is crude, and it is ours, which are both the point. The boundary is now something we can state, argue about and change, rather than an artefact of somebody else's ranking. **Where we were wrong: complaint volume is not demand** The original design mined negative reviews. The reasoning was that a leader's angriest customers describe the product you should build, and that a category with many angry reviews is a category with unmet demand. The claim about what to build survived contact with the data. The claim about demand did not. Across all 34 categories we profiled, the dominant complaint is price. Not in most of them. In every single one, at between 25% and 49% of the negative reviews. That inverts the signal. The people who write the loudest complaints in a category are disproportionately the people who did not want to pay for it. Complaint volume is therefore not a proxy for willingness to pay; it runs closer to the other way. We had been treating complaint volume as demand. It is nearer to the opposite. What survives is the content of the complaints, not their count. A complaint about a missing feature is a specification. A complaint about price is a statement that the writer was never going to be the customer. The two have to be read apart, and only the first belongs in a case for building anything. **What actually disqualifies a category** One category we measured takes over seventy-six thousand installs a month. By the criterion we started with, that is a large, live market and an obvious candidate. It is still not worth entering, because the platform ships a preinstalled app that does the same job. Free, already present, already the default. No arrival rate changes that. The size criterion weakened from the other end as well. Once a portfolio is spread across several small apps rather than concentrated in one, the audience each app has to win falls far enough that category size stops being the binding constraint. A category too small to carry a business on its own can still be worth one app among several. So size — the number the whole scanner was built around — turned out to be nearly the least important thing it reports. The free-canonical check ends an analysis on its own. Size, on its own, ends nothing. **A trap in the public review feed** Worth recording for anyone building the same thing. The public review feed returns a single object rather than a list when a page holds only one review. Code written to walk a list of reviews therefore does not process that review — it raises a type error instead of returning the one item. The reason this is worse than it looks is where it occurs. It can only occur on categories with almost no reviews. A scanner can run cleanly across every large category, look correct in every log line, and be blind on exactly the small, quiet ones you built it to find. Handle the single-object case explicitly at the point where the feed is parsed, not further down. Then test it against a category you already know is nearly empty, so the branch has actually executed once before you trust a run. **How to run the same check** Build the category from a relevance filter you control rather than from the platform's ranking, and write the rule down. If you cannot state why a given app is in your set, it is not your set and the averages over it mean nothing. Derive an arrival rate from dated reviews rather than from totals. Read what the negative reviews are actually about before treating their number as demand, and count price complaints separately from feature complaints — they point in opposite directions. Check for a preinstalled or free canonical competitor before anything else. It is the only finding that ends the analysis on its own, which makes it the cheapest thing to run first. **Limitations** Review counts are a proxy for downloads with an unknown conversion between them, and that conversion is category-dependent. Rates should be compared across categories rather than read as absolute demand. A category with a higher review rate than another is not reliably a category with more users. The category count and the complaint percentages come from our own profiling run, and the relevance filter is ours. Someone else's filter would draw slightly different category boundaries, and slightly different numbers would come out the other side. ### The data failures that cost most never raised an alarm Our costliest data failures ran for days without an error, an alert, or a broken page. Each kept producing numbers on schedule; only the freshness of the data was wrong. The mechanisms we used to catch them are general; the intervals inside them are ours. **Nothing crashed** We went looking for the failures that had cost us the most, expecting to find outages. Instead we found incidents in our own data plumbing that never threw an error, never paged anyone, and never stopped producing numbers on schedule. The numbers were simply wrong. The failure mode we had built defences for was the loud one. A job exits non-zero. A connection is refused. A page returns a stack trace. Those get fixed quickly because somebody notices immediately. The failures described here kept running because every surface anyone looked at was green. The shape they share is worth stating before the detail. Every one of them produced output. The pipeline ran. Only the freshness of the data was wrong, and nothing in the system was checking freshness. **Different failures, one shape** Every advertising figure for one platform flowed through a single long-lived OAuth token, with a life of roughly sixty days, covering every advertising account we run. When it expired, everything stopped at once, and it stopped quietly. Reports still rendered. They just stopped moving forward. The signature is worth memorising, because it misleads on first reading. Every account's last sync timestamp was frozen on the same day, and the sync log reported a failure naming one account at random while in fact all of them had failed. Read the log alone and you conclude you have a single broken account, not a dead credential underneath the whole platform. Another failure was an absence. A scheduled job for a second platform stopped running and nobody knew, for a period we still cannot state. There is no record of when it stopped, because a job that never starts writes nothing at all, not even a failure line. The expensive one was a profitability table that went unsynchronised for 23 days. The monthly figures it fed were badly wrong for the whole of that time, and no page said so. The dashboards rendered as usual, at the usual speed, in the usual layout, and people read decisions off them. **Exit zero is not evidence** A job that exits zero tells you a process reached its end. It does not tell you that the data changed. Those are different claims, and almost all of our monitoring was asserting the first while everyone downstream assumed the second. The absence case is worse. A job that never starts cannot report its own failure, so no amount of log inspection will surface it. You can only detect it by noticing that something expected did not arrive, which means the alarm has to live outside the job. So we moved the assertion from the job to the output. Is this table fresher than N hours? That question has an answer regardless of which component broke, regardless of how it broke, and regardless of whether any component ran at all. **What we changed** Watchdogs now check output against a service level rather than checking that a job exited zero. For each table that feeds a decision we wrote down the maximum age at which it is still trustworthy, and the watchdog compares the newest row against that age. It is a claim about data, not about processes. Each scheduled job pings on SUCCESS, so the absence of a ping is itself the alarm. This is the only way to cover the job that never starts. The system is no longer relying on a failing component to describe its own failure. Independent sources of the same quantity are reconciled against each other, and the drift history is stored. Email goes out only on NEW drift. This part is about human attention rather than engineering: an alert that fires every day is an alert nobody reads, and a channel full of known noise hides the one line that matters. Token expiry is now checked ahead of time rather than discovered by an outage. Where a single credential covers every account on a platform, its expiry date is a scheduled event we can see coming, not a symptom we diagnose afterwards. **We were wrong about the monitor itself** The first version of one of these checkers reported a working automation as dead. It was reading a history table that happened to be empty, while the real effect was being written to a different column entirely. The automation was fine. The checker was not. The assumption underneath, which we had not noticed making, was that the place recording an action is the same place that shows its effect. Here it was not. We came close to repairing something that already worked, on the authority of a monitor we had just written and had not tested. A monitor can measure the wrong thing just as easily as the system it monitors, and it is more dangerous when it does. A monitor is the thing you consult precisely when you distrust everything else, so a wrong one has no second opinion above it. Confirm first that you are measuring the effect, then trust the monitor. **Running the same check yourself** Start from effects, not from files or jobs. For each automation, write down what it changes about a real record. Then find the column or table where that change actually lands. That is the thing to measure, and it is often not the log, the run table or the history table you would reach for first. For every table that feeds a decision, take the newest timestamp and compare it to now, against a freshness limit you have written down in advance. Then repeat the comparison per entity, not just overall. The credential failure above only reveals itself as a pattern if you group by account and see every account frozen on the same day. Invert one alarm as an experiment. Take your most important scheduled job, make it ping on success, then stop it deliberately and confirm that you hear about it. If you do not, you have learned something more useful than a passing test. Finally, pick a quantity you can obtain from independent sources, store the difference between them over time, and alert only when that difference changes. **Limitations** This is one company's data plumbing. The mechanisms are general and can be copied directly. The specific intervals and thresholds are not: our freshness limits come from how often our own pipelines run and how fast our own decisions are made, and copying those numbers would tell you nothing about your systems. Freshness monitoring catches staleness, not wrongness. Data can arrive on time, pass every watchdog, and still be misattributed, mismapped or double-counted. Nothing described here would detect that, and we do not claim these were the only silent failures we have, or that we now catch all of them. We also give no figure for what any of this cost. For the job that stopped, we do not know the period it was down. For the rest, the honest accounting would require reconstructing decisions people made from wrong screens, and we cannot check that, so we are not publishing a number for it. ### Cost per order is wrong by exactly the confirmation rate In cash-on-delivery retail, dividing advertising spend by orders placed gives a figure that is smaller than the real one by a factor you can know in advance. We published the optimistic version first and were corrected. **An order placed is not an order** In cash-on-delivery retail, submitting the form on the website does not end the sale. Support telephones the customer to confirm the order. A substantial share are cancelled at that call and never enter fulfilment at all. Nothing is picked, nothing is packed, nothing is dispatched. A second loss follows later, at the door. The customer sees the parcel and refuses it. In this business roughly 13% of parcels are refused on delivery. That is a separate leak with its own denominator, and it is not the subject of this article. So a per-order cost can be built on orders placed, on orders that entered fulfilment, or on orders delivered. These are different populations. Orders placed is the default because it is the number the shop platform reports and the number the advertising platform can be reconciled against, not because it is the number the business is buying. **One store, one day** One store, one complete day. Orders placed on the website: 182. Orders that actually entered fulfilment for the same day: 121. The fulfilment count breaks down by state as 109 processed, 8 already in transit, 3 cancelled and 1 on hold. The states sum to the total, so the figure is auditable rather than asserted. Anyone can check the addition without access to our systems. The missing 61 were cancelled at the confirmation call. That is consistent with the confirmation rate at cash on delivery, which runs at about 66% for this business. Roughly a third of what the website records as an order never becomes work in the warehouse. **The error is exactly the confirmation rate** Divide the same advertising spend by 182 instead of 121 and you get a cost per order that is about two thirds of the real one. This is not a coincidence and not an approximation. The numerator is identical; only the denominator changed. The ratio between the wrong figure and the right one is the confirmation rate. That has a useful consequence. The size of the error is knowable in advance, without recomputing anything. A team that knows its confirmation rate knows precisely how wrong every per-order cost it has ever published against placed orders is, and can correct the entire published history. The same logic extends further. If you want cost per delivered order, the refusal rate applies on top of the confirmation rate, on the same principle: same numerator, narrower denominator. We have not computed that figure here. **Same campaign, same day, opposite decision** The optimistic figure showed comfortable headroom above break-even. Read on its own terms it carried an obvious instruction: spend more, the campaign has room. The corrected figure showed a modest margin instead. Same campaign, same day, same spend, and the opposite decision. That is why the denominator is worth an article. The error was not a rounding difference in a report nobody acts on. It changed a verdict that money follows. **Where we were wrong** We published the optimistic figure first. The owner corrected it. The mistake was not arithmetic. The division was right, the spend was right, the order count from the shop platform was right for what it counts. The mistake was using a denominator that counts an event — someone submitted a form — rather than the event the business is actually buying, which is an order that will be picked, packed and dispatched. The assumption that failed was that the shop platform's order count and the business's order count are the same number. In a market with a confirmation call they are not, and the gap is large enough to invert a decision. Any measurement that crosses a system boundary deserves the same suspicion: the two systems are counting, but they may not be counting the same thing. **How to run the same check** Pick one store and one complete day. Count the orders in the shop platform. Count the orders in the fulfilment system for the same local day. Divide one by the other. Details decide whether the result means anything. The timezone first: a day boundary taken in the wrong zone moves orders between days and manufactures a difference that looks like a confirmation gap but is a clock error. Then the state breakdown: check that the states sum to the total, so the number is auditable rather than asserted. If the two counts differ, every per-order cost you have published is wrong by that factor, and you can correct the whole history without recomputing anything. If they match, you have confirmed that they match, which is also worth knowing. **Limitations** This is one store on one complete day, in a market where cash on delivery dominates and a confirmation call before dispatch is standard practice. A single day can sit above or below the usual pattern, and one store is not a portfolio. The confirmation rate of about 66% and the refusal rate of roughly 13% are this business's numbers, not constants. Where payment is taken up front, the gap between orders placed and orders that enter fulfilment largely disappears, and the correction described here is close to a no-op. What generalises is not the rates but the arithmetic that links them to the size of the error. Same numerator, different denominator, ratio equal to the pass-through rate between the two populations. That holds wherever a funnel step sits between the count you report and the event you are paying for. ### The bug on a browser we did not have Product titles overflowed on phones and nowhere else. Three environments that claimed to be the mobile rendering all said the page was fine. Counting one CSS value across the live page found the cause without ever reproducing it. **What the page did** On a shop's home page, product titles overflowed onto the badge and onto the price. Only on phones. On every desktop surface we had, the same page rendered correctly. The shape of the failure matters. This was not text wrapping awkwardly into a container that was slightly too small. The text box collapsed to a single line while the text carried on being painted across three or four lines, straight over whatever came next. The browser in question was not on any machine we owned. That is an ordinary situation, and the reflex is to reproduce the defect first. We took that route, and it cost us. **Three environments, and all three were wrong** First attempt: a desktop browser with mobile emulation. The page rendered correctly. Second: the page builder's own editor. Correct. Third: we wrote a small native app that wrapped the platform's web view and loaded the page inside it. Also correct. Three environments, each claiming to be the mobile rendering, and all three were wrong about it. Emulation changes the viewport and the user agent; it does not change the engine that resolves the CSS. The builder's editor runs inside the desktop browser you already have open. The web view we wrapped resolved the declaration exactly as desktop did. The general form is worth stating plainly: time spent trying to reproduce a bug is wasted whenever the bug is a rendering difference between engines, because the reproduction attempt is asking the wrong engine. Every it works here was a true statement about the wrong thing. **Counting instead of reproducing** So we stopped reproducing and started counting. We pulled the live page's HTML and built a histogram of one custom property across every text block on the page. 42 blocks carried the value unset. Every one of them rendered correctly. Exactly 6 carried the value zero. They were exactly the 6 broken ones. Same CSS rule, same page, same browser. The only difference between the healthy set and the broken set was the value. That is a controlled comparison, and the live page had already run it for us — we only had to read it off. **Why zero collapses the box** The builder's "maximum lines" setting, when left at zero, emits the declaration -webkit-line-clamp: 0. Zero is not a valid value for that property. Engines disagree about invalid values. Desktop browsers, the builder's own editor and the desktop web view all degrade this one silently to none and render as though it had never been written. One mobile browser applies it literally: the text box collapses to a single line while the text carries on being painted across three or four lines, overflowing whatever comes next. There is a second trap in the same control. The "off" switch writes zero as well, so "no limit" and "a limit of zero lines" reach the browser as the same declaration. The only safe state is "on" with a positive number. **How to run the same check** When something renders wrong only on one device class, and you do not have that device class, stop trying to reproduce it and start counting. Fetch the live HTML. Extract every value of the suspicious property or custom variable. Build a frequency table. Then look for the rare value and test membership: are the elements carrying it exactly the broken ones? Exactly is the word that does the work. A rare value that covers the broken elements and some healthy ones as well is a weaker signal, not a cause. If the match is exact, you have your cause without ever owning the device. All you need is the list of broken elements and the markup, which you can fetch from anywhere. **Limitations** This works when the failing elements are numerous enough to compare against healthy ones on the same page. A broken element with no healthy counterpart beside it gives you nothing to compare against. It also requires the difference to be expressed in the markup rather than computed at runtime. A value written by script after load, or resolved from a media query that the fetch never triggers, will not appear in the HTML you pulled. Finally, it identifies a correlation on one page. It does not by itself prove the mechanism. For that we still had to know that zero is invalid for that property, and that engines disagree about what to do with invalid values. ### A negative search result is not evidence of absence 12 of 20 app listings sat rejected for over two weeks because the reviewers' questions lived in a ticket system, and the mailbox search that would have found the notifications was never validated. **What we found** A routine check of our marketplace listings returned a number nobody expected: 12 of 20 were sitting rejected. Not queued, not in review — rejected, and rejected for over two weeks. Nobody on our side had reacted, because nobody on our side knew. The interesting part is not the count. It is that a rejection there is not necessarily a verdict on the software. Each submission to that marketplace opens a support ticket, and every question a reviewer asks lives in that ticket. One of the available rejection reasons is simply the review expiring because nobody answered. So a listing can fail for silence. The code builds, the app runs, the reviewer asks something ordinary, and the clock runs out in a room we were not standing in. **The verdict lives in the ticket, not the inbox** Treat this as an architectural fact about the marketplace rather than a matter of etiquette. The ticket is the channel of record. The email is a notification about the ticket. Read the notification and you learn that something happened; open the ticket and you learn what. The distinction matters because the two channels fail in different ways. Mail can be filtered, filed under a category, marked read by a sweep, or simply never matched by the query you ran. The ticket cannot: it holds the state, and the state is what the review acts on. Our mental model had email as the primary channel. That is the one assumption everything else went wrong on top of. **Where we were wrong** We searched the mailbox for the marketplace's main domain. Nothing came back. The error was twofold. First, the notifications are sent from a different domain belonging to the same company, so the query could not match by construction. Second, the mail client filed those messages under a secondary category, so they never surfaced in the primary inbox either — two independent causes of the same silence. Then we did the damaging thing. On the strength of an empty result we told the owner there had been no review correspondence at all, and we built a theory of the rejections on top of that. The mailbox in fact held 83 messages from the notification domain, 77 of them carrying ticket links, covering 57 distinct ticket ids. All of them unread. The lesson is narrower and more useful than "check your email". A negative search result is not evidence of absence until the query itself has been validated. An empty result is a statement about your query first and about the world second, and from the result alone you cannot tell the two apart. Validation is cheap: obtain a known-good message by another route, then confirm that your query actually returns that message. If it does not, the query was wrong, and every conclusion drawn from its emptiness is void. **What the silence cost** The unanswered tickets expired in a batch. 13 listings were rejected for timeout. 2 more were rejected for a genuine security defect — real, ours, and it had to be fixed. All of them were repaired and resubmitted the same day. The temptation at that point is to resubmit everything and move on. It does not work: resubmitting without reading the ticket only restarts the same clock, with the same unanswered question waiting at the far end. One listing we resubmitted blind moved from "timeout" to a real verdict that had been sitting in the ticket all along. We had bought a delay and learned nothing. A timeout rejection carries no information about the software. It only tells you where you were not looking. **Access to your own ticket is not guaranteed** One further obstacle deserves stating plainly, because it breaks the obvious fix. When we went to read the backlog, 7 of 12 tickets returned "no access" to the very account that had opened them. Requesting access is itself a ticket. The recovery path for a ticket you cannot read therefore runs through the same system that is timing you out, which makes an access problem discovered late an access problem discovered too late. The process consequence is simple: prove you can open the ticket at submission time, while somebody is still watching and the clock does not yet matter. **How to run the same check** At submission time, capture the ticket identifier, open it once to prove you can, and put it on a poll. A submission is not complete when the form is sent; it is complete when the ticket it created is identified, readable and being watched. Then validate every negative search before you report it. Confirm the sender domain from a known-good message instead of assuming it matches the marketplace's public domain. Include the spam folder and the secondary categories in the query. If it still returns nothing, say "the query returned nothing" — and only say "there was no correspondence" once the query has demonstrated that it finds a message you know exists. What we run now is small: an alarm twice a day against the two channels that actually carry a verdict. It classifies each message and raises a notification only when something needs an answer. The value is not in the classifier; it is that the check is periodic and does not depend on anyone remembering. **Limitations** This is one company's experience of a handful of marketplaces over a short period. The specific domains, categories and interfaces will differ, as will the rejection reasons a reviewer has available. Nothing here describes how any particular marketplace behaves today. What generalises is the shape: a review conversation held somewhere other than where you are looking, and a null search result accepted as a fact. Both are cheap to test for and expensive to leave untested. The counts are from the moment we looked. We have not measured how often the pattern recurs, nor whether an alarm twice a day is frequent enough for the deadlines each marketplace sets. ### When a bug wears the fallback's message A parcel tracker reported missing credentials for every parcel. The credentials were there. A broad except clause kept turning each attempted fix into the same reassuring sentence. **A crash and an empty result are different facts** A multi-courier parcel tracker running on a server reported the same thing for every parcel it was given: credentials missing. That is a specific claim. It says the code ran, reached the point where it needed a secret, looked, and found nothing. Read that way the fix is an operations task — put the credentials where the tracker expects them — and it is not obviously urgent, because nothing is broken, only unconfigured. None of that was true. The credentials were present. The code never reached the point the message implied. The tracker crashed inside the path that loads a helper, and the crash was caught and converted into an empty result, which the caller then reported as an absence of data. The gap between those two readings — it crashed versus it found nothing — is the whole article. A crash points at a line. An empty result points at a database, a config file, an ops process, somebody else's system. One of those is a short investigation. The other is days of work. **Two causes, stacked** The tracker computed the path to a helper by walking up a fixed number of directories from its own file. In the source tree where it was written, that was correct. On the server the files were deployed flat, so the same computation walked past the top and produced a path at the filesystem root — a path that could not exist. The fix was not clever: search a list of candidate locations and take the first one that is there. A neighbouring script in the same directory already did exactly that. The old code was not wrong when it was written; it encoded an assumption about layout, and deployment quietly broke the assumption without touching the code. The second cause sat underneath the first. The tracker asked the credential store for a single entry holding the courier credentials. That entry had never existed. The store keeps them separately, one entry per courier. Nothing was missing; the tracker was asking for a name nobody had ever written. Both causes produce the same symptom, so repairing either one alone changes nothing you can see from outside. That is worth stating plainly, because I fixed it and the output did not change is normally read as evidence that the diagnosis was wrong. **Where we were wrong: three attempts, one visible outcome** There were three attempts. The first fix called a function from a module that was not imported in that file. At runtime that raises a name error — in Python, a NameError, thrown at the moment the line executes rather than when the file is loaded. The name error was raised inside the try block of the fallback, and the fallback's handler was except Exception: continue. A name error is an exception. So the fix ran, failed silently, the loop moved to the next candidate, and the tracker reported exactly the same credentials-missing message as before. Two consecutive attempts looked identical from the outside. The natural conclusion at that point is that the diagnosis is wrong and the real cause is somewhere else. That conclusion was itself wrong. The diagnosis had been right both times; the evidence we were reading was produced by the handler, not by the system. We were reading the fallback's opinion of the world and treating it as an observation of the world. The change that mattered was to narrow the handler to the single failure the fallback exists to survive: a file that is not present. Everything else now propagates. On the next run the name error appeared, and the name error named the line. **An except clause should name the failure it exists for** The rule that comes out of this is narrow enough to apply mechanically. An except clause in a fallback path should name the one failure the fallback was written to handle, and nothing else. A broad handler in that position does not make a system robust. It converts every bug inside the try block into the fallback's own message. The bug can be a typo, a missing import, a renamed attribute, a wrong argument count — all of them come out the far side wearing the same sentence. The asymmetry is what makes it expensive. The person writing the fallback has one benign case in mind, and the message they write is calm: data unavailable, source empty, nothing found. Everyone who later reads that output inherits the expectation, including the author. Catching broadly is a claim that you anticipated every failure inside the block, and it is almost never a claim anyone intended to make. **How to run the same check** Search your codebase for broad exception handlers whose body is a continue, a pass, or a return of an empty value — an empty list, an empty dict, a null, an empty string. The body is the tell, not the clause: a broad handler that logs the exception and re-raises is a different thing and is usually fine. For each hit, ask what specific failure it was written for, and narrow the clause to that failure. If you cannot name the failure, the handler is not handling anything. It is hiding something, and what it hides is whichever bug lands inside the block next. Then verify by effect, not by absence. Run the thing and require a real result — a parcel with a status attached — rather than the absence of an error message. Throughout this incident the process exited cleanly and printed a sentence that parsed as an explanation; a check that asserted only it did not crash was true the entire time and told us nothing. **Limitations** Narrowing a handler surfaces errors that were previously invisible. On the day you deploy it, that looks like a regression: new tracebacks, often in code you did not touch. It is not a regression, it is the handler working, and the deployment should be planned expecting it rather than rolled back on the first traceback. The examples here come from one script. We have not measured how common the pattern is across the rest of the code, and this article makes no claim about a rate. The pattern is not language-specific, though the exact syntax is. Narrowing also requires judgement, and judgement can be wrong in the other direction. Name the exception too tightly and a legitimately benign failure starts propagating into production. The test for that is the same question as before: can you say out loud which failure the fallback exists to survive? When the answer changes, the clause changes with it. ### The ignore pattern that deleted a live module A data directory was ignored without a leading slash, so the pattern also matched a source package deeper in the tree. The package was never committed, a mirror deploy removed it from the server, and the only surviving description of what it did was its test suite. **What broke** A service stopped starting and went into a restart loop. The immediate cause was ordinary: a live route imported a module, the module was not on disk, and the import failed with ModuleNotFoundError before the application could serve anything. Nobody had deleted the file on purpose. The chain runs from an ignore pattern to a deploy script. The ignore file listed a directory of exported data files. The pattern was written without a leading slash. Somewhere deeper in the source tree there was a package with the same directory name, and the pattern matched that too, because the pattern matches a name, not a location. So the package was never committed. It existed on the disk of the person who wrote it, and on the server, where an older archive-based deploy had once placed it. Both copies looked healthy for as long as nobody rebuilt the server from a clone. **A pattern without a leading slash matches at any depth** This is documented behaviour of the standard ignore-file syntax, not a bug and not an edge case. If a pattern contains a slash at its start or in the middle, it is anchored: it applies relative to the directory of the ignore file. If it contains no such slash, it may match at any level below that directory. A bare directory name is therefore a wildcard over the whole tree. That is the right default for things like build output and editor droppings, which appear anywhere. It is the wrong default for one specific directory that holds exported data at the root of the repository, because the same word is a perfectly reasonable name for a package of code. The failure mode is quiet by design. An ignored file is not reported as untracked, so the normal status output stayed clean for as long as the package was ignored. There was no warning to miss. The tooling did exactly what it was told. **A mirror deploy makes the deploying machine the source of truth** The deploy script removes the source directories on the server and unpacks an archive built from the deploying machine's disk. That is a mirror, not a merge. Anything present on the server and absent from the archive is gone after the deploy, whether it was stale rubbish or a module a live route depends on. The deploying machine had cloned from the repository, so its disk held exactly the tracked files. The ignored package was not among them. The archive was therefore correct with respect to version control and wrong with respect to reality, and the deploy propagated that difference onto the server. Stated generally: a deploy that mirrors a disk silently deletes any real file that is present on the server but absent from the deploying machine. The machine doing the deploying becomes the definition of the application. If that machine is missing something, the deploy is a deletion with extra steps. **We expected to undelete the file. We were wrong** The first plan was recovery from the server. This is the plan most people reach for, and it assumes the deleted data is still sitting in unreferenced blocks waiting to be reassembled. On modern filesystems that assumption no longer holds: the block map is zeroed on unlink, so the pointers from the inode to the data are destroyed before the data is. The usual forensic recovery tool returned no recoverable inodes at all — not a damaged partial reconstruction, nothing. What saved the module was the tests. They were tracked, because no ignore pattern matched their path. There were 32 tests in one file and 6 in another, and together they described the exact behaviour required: inputs, outputs, error cases, the shape of every value the rest of the system expected back. The module was rebuilt from its test suite used as a specification. The lesson we took from this is not about the ignore file. It is that a test suite is the only description of a module that survives the module. Documentation drifts, comments are lost with the file they annotate, and a recovery tool can return nothing. A test that runs is an executable statement of intent that lives in a different file. That is a stronger argument for writing tests than any coverage target we have ever been given. **Two preventions, both cheap** Anchor ignore patterns for data directories to the repository root with a leading slash. The pattern then applies to the one directory it was written for and cannot match a same-named directory inside the source tree. This removes the entire class of accident. Before any deploy that mirrors a disk onto a server, import the application's entry module on the machine doing the deploying. An import walks the real dependency graph and touches every module a live route needs. If the import fails locally, it will fail on the server too — and you find out before the server does, on a machine where fixing it is a checkout rather than an incident. Neither prevention needs a new tool, a policy document or anyone's agreement. The reason they were not there is the reason such precautions are usually missing: until the day the ignore pattern met the deploy script, each looked harmless on its own. **How to run the same check** List every file the repository is currently ignoring that is also present on the server, and ask of each one: is this data or is this code? Data is expected to be ignored and expected to differ between machines. Code that exists only on the server is a module you do not have, and the next mirror deploy will remove it. On the repository side, git ls-files --others --ignored --exclude-standard enumerates what is being ignored; compare that list against the deployed tree. Then re-read every ignore pattern that lacks a leading slash and decide whether it was meant to match at any depth. Some are: caches and compiled artefacts belong everywhere. Named directories that sit at the root of the repository almost never are. For any file you are unsure about, git check-ignore -v names the exact pattern and line that excluded it, which turns the question from a guess into a lookup. Finally, run the import check by hand before wiring it into the deploy, so you see what a clean pass looks like on a machine you trust. A pass proves the dependency graph is fully present on the disk about to become the source of truth. **Limitations** The recovery outcome is specific to the filesystem. On a filesystem that does not zero the block map on unlink, an undelete may well succeed, and the conclusion about tests would have arrived as a preference rather than as the only option left. Do not read "recovery is impossible" into this; read "do not plan on it". The pattern-matching behaviour is not specific to us. It is the documented behaviour of the standard ignore-file syntax, and it generalises to any repository using it. This is one incident in one codebase. The mechanism is general, the two preventions are cheap enough to apply without evidence, but the frequency is not something a single case can tell you. ### Two signals, or a person Every plausible rule for automatically repairing a broken delivery address, measured against real orders — and why most of them were rejected. **The rule we kept** Cleaning up broken delivery addresses in bulk looks like an obvious win. The rule we ended up with is narrower than any of the ones we tried: a broken address field is repaired only when two independent signals agree. One signal, or any ambiguity, and the order goes to a person. Independent carries most of the weight in that sentence. A postcode recovered from a broken field and an official locality nomenclature are independent: one comes from what the customer typed, the other from a published register, and they fail for different reasons. Re-reading the same typed field is not an independent signal, however different the parsers look. The rule is also late by design. It runs only on an address the courier has already rejected, never preventively across the whole order book. Every part of that sentence turned out to matter, and the measurements below are the reason. **What the rule did, order by order** Once the broken postcode field was cleaned, the digits left formed a code, and the official locality nomenclature listed that same locality at that same code. Two signals agreed. Repaired, label issued. A postcode prefix resolved to exactly one city, and the city field held a truncated version of that city's name. Two signals again, but only about the city. The city was written; the postcode was left invalid, because nothing confirmed the remaining digits. We correct the field we can confirm and no more. A postcode prefix resolved to six possible localities. One signal, ambiguous. Sent to a person. A postcode field containing a place name with no digits at all, and a city field containing the generic word for a rural settlement. Nothing to work with. Sent to a person. What is worth noticing here is that a cleanup pass which "does its best" would have produced a confident answer for the escalated orders as well. **The trap that nearly shipped** We were wrong about what a postcode file contains. In one country's official postcode source, the string 00-000 is not a postcode. It is the "not filled in" marker. It appears in 13,222 of 341,720 rows. Because it is so frequent, it comes out first when you count the most common postcode per locality — for 557 localities, including three of the country's largest cities, where it appears 2,220, 1,346 and 1,282 times respectively. The existing repair rule would therefore have written 00-000 onto an order from a major city. The parcel would have gone nowhere. It was caught by a regression test before it ever ran on a real order. Four neighbouring countries have no such marker: zero rows. That is the part we got wrong, stated precisely. The assumption that failed was not this file is clean but a file that is clean in the neighbouring countries is clean here too. A sentinel value defeats the mode specifically, and the mode was the statistic we had chosen to trust. **Two rules that sounded obviously right** The rules below were proposed, measured against real orders, and rejected. Each sounded obviously right. Each would have touched far more working orders than broken ones. "City field contains the county name, so take the city from the postcode": touches 8,166 orders, of which 18 were actually blocked. One in 453. "The house number is in the second address line, so move it to the first": touches 2,652 orders, of which 4 were blocked. One in 663. The denominator is the whole argument. A rule with numbers like those is not an address repair; it is a mass edit of addresses that were already delivering, justified by the fact that they looked wrong to us. Blast radius is cheaper to measure than precision, and it should be measured first. **Why the repair runs last** A blind cleanup invents a destination. Confirmation from an independent source is what turns a guess into a correction, and without it a repair is indistinguishable from a plausible fabrication written into a shipping label. Restricting the repair to addresses the courier has already rejected changes what the rule can touch. It uses the rejection itself as evidence that something is wrong, and it keeps every rule away from the orders that were delivering fine — exactly the orders the two rejected rules would have modified. The costs are asymmetric, which settles the ambiguous cases. An escalation costs a person's handling time on one order. A wrong repair produces a parcel that goes nowhere, a customer who is not told, and a courier record that now looks correct. So ambiguity always resolves towards the person. **Running the same check** Measure before you repair. Run each proposed rule's predicate as a pure filter over a window of real orders and count how many orders it touches, then how many of those the courier actually rejected. The ratio between them decides whether the rule exists at all, and it takes a query rather than a design discussion. For nomenclature files, count the frequency of every postcode value before trusting any aggregate computed from them. A value that appears far more than the rest, and appears across unrelated localities, is a sentinel marker rather than real data. Then run the same count on the neighbouring countries' files: a marker present in one country and absent in the others shows up immediately as a difference in row counts. Keep a regression test per rule, with the pathological rows written into it. Ours caught the sentinel. **Limitations** This is one courier ecosystem, a handful of countries, and nomenclature files whose quality differs by country. A marker that exists in one country's register and not in its neighbours' is exactly the kind of thing that will differ again in a country we have not touched. The ratios above come from one measurement window. Treat them as evidence that the method finds bad rules, not as constants to carry into another dataset. The same two rules measured elsewhere could easily produce different numbers; what should transfer is the habit of counting the orders a rule touches and comparing them against the blocked ones before shipping anything. The two-signal rule is only as strong as the sources behind the second signal. Where the register itself is wrong, two signals can agree and still be wrong together — the sentinel case was precisely that, caught by a test rather than by the rule. And we measured the rules we thought of. Rules nobody proposed remain unmeasured, which is not the same as safe. ### Geography predicts refusals. Shipping cost only leaks the answer. A refusal model trained on roughly 628,000 finished orders scores AUC 0.686 on an honest temporal holdout, and its riskiest fifth of orders holds 39% of all refusals. The first version scored 0.55 because it was reading the answer off a field that only exists after the parcel comes back. Most of the address rules we were confident about did nothing once we put a control group next to them. **What the ranking buys you** The model is trained on roughly 628,000 finished orders. About 16% of them were refused. 92% were cash on delivery, which is the mechanism that makes a refusal nearly free for the customer and expensive for us: the parcel travels twice and nobody pays for it. We evaluate on a temporal holdout. Train on the past, test on the most recent weeks, never on a random split. A random split lets the model learn from orders that happened after the ones it is scoring, and every delivery dataset has enough seasonality and enough repeat customers for that to matter. On the honest split the model scores AUC 0.686. What that is worth in operations: the riskiest 20% of orders contains 39% of all refusals, at 29% precision, which is 1.9x the base rate. Tighten the cut to the riskiest 10% and it holds 24% of refusals at 37% precision. That is a call list ordered by expected waste, not a judgement about any one parcel. The strongest features are postcode and city. Geography is the single strongest predictor in this dataset. Order value and the customer's own prior refusal rate, keyed on phone number, are also among the strongest. What the model leans on describes where the parcel is going and who ordered it, not what is in the box. **The first model was reading the answer** The first run scored AUC 0.55 and shipping cost dominated the feature importances. That combination should have been the first warning. A field that dominates a model and still leaves it barely above chance is usually not a feature. It is a symptom. Shipping cost leaks the label. A returned parcel costs more, because the recorded figure includes the return leg. The number is therefore partly a description of the outcome we are trying to predict, and, more importantly, it does not exist at the moment the prediction is needed. When the order is placed, nobody knows the parcel will travel back. Removing shipping cost and the other post-fulfilment fields raised the honest AUC from 0.55 to 0.686. That direction surprised us, because leakage is supposed to inflate a score rather than depress it. The explanation we find most plausible, and we did not need to settle it, is that the return leg is billed late, so in the recent weeks used for testing the extra cost had not landed yet. The model leaned on a column that was informative in the training period and empty of that information exactly where it was scored. The rule we now apply without exception: in a delivery or refusal model, never use a field that is only known after the outcome. The practical version is to go column by column and name the process that writes it. Anything written by fulfilment, by courier settlement, or by the returns desk is out, however strong it looks. **Two obvious features that did nothing** We then added two features we were confident about: phone-number validity, and a parse of address completeness. Both are cheap to compute, both are standard advice, and both describe exactly the kind of sloppiness we assumed precedes a refusal. AUC moved from 0.686 to 0.687. Neither feature appeared in the top fifteen by gain. The reason is not that the idea is wrong. It is that the signal was already in the model. Geography and the customer's own history carry it, and a structurally invalid phone number is rare enough that there is almost no variance left for the feature to explain. A feature can be true and still be redundant. So the check to run before adding an obvious feature is whether it is already subsumed. Train with it and without it on the same holdout, and read both the score and where it lands in the importances. An idea that survives that test is worth the code. One that does not is a maintenance cost with nothing in return. **The address study, and what a control group does to it** The second measurement is separate from the model. On 24 August 2026 we took 7,426 orders that the courier itself had flagged as having a bad address, and compared every candidate signal against a control group of orders it had not flagged. The control group is the whole point. Without it, any rule that fires often enough will produce convincing examples. Our own address validator called 95% of those orders valid. Its objections turned out to predict almost nothing. Addresses with no house number ended in a loss 8.9% of the time. Addresses whose postcode could not be derived came in at 7.0%, which is below the 8.4% average. A street field the validator classed as garbage gave 9.7%. Only one objection carried real signal: locality not found, at 15.0%, about twice the average. The best signal we produced ourselves is cruder than any of those rules. An address containing no digit at all appears on 14.6% of courier-rejected orders versus 3.2% in the control group, a factor of 4.5. No street number, no block, no flat, nothing numeric anywhere in the field. The strongest signal was not ours at all. It was the courier integration's own address verdict, which we had been treating as noise. Its INCOMPLETE verdict appears on 13.2% of rejected orders versus 0.9% of the control, a factor of 14.7. UNKNOWN appears on 7.2% versus 0.5%, a factor of 15.4. Together those two verdicts catch 20.4% of all courier rejections while touching only 1.4% of orders, which is the ratio that makes a manual queue affordable. The same integration's WRONG verdict is weak by comparison, at 1.8 times. **Four assumptions the control group killed** A village address is not riskier than a city address: 8.1% against 8.3%. We had built the opposite into our intuition, and into the way we talked about rural deliveries. A block-of-flats address is safer, not riskier, at 7.1%. A missing postcode is below average at 5.6%, which fits the finding that our validator's postcode objections are worthless. And the absence of a street-type prefix made no difference at all. Every one of these beliefs came from real cases. We had all seen a parcel lost to a village address with no number on the house. What we had not done was ask how often the same trait appears on the parcels that arrive without incident. That question is the control group, and it is the cheapest instrument in the whole exercise. **How to run the same check yourself** Split by time, not at random. Fit on everything up to a cut-off date and score the weeks after it. If the score drops when you move from a random split to a temporal one, the earlier number was never real. Audit every column for its write time. For each field, name the process that fills it and the point in the order's life at which it stops being null. Fields written by fulfilment, courier settlement or returns are unusable, even when they look like properties of the order. Shipping cost is the trap, because on old rows it is populated and looks static. Measure every rule against a control group, and report the ratio rather than the raw rate. A rule that fires on 8.9% of bad orders means nothing until you know what it does on good ones. Put the base rate in the same table, so an objection that lands below it is visible immediately. Then check coverage before you celebrate a ratio. A rule with a factor of 15.4 that touches a handful of orders is a curiosity. The pair of courier verdicts above is useful because 20.4% of rejections sit inside 1.4% of orders. The ratio tells you whether to trust a flag. Coverage tells you whether to build a process around it. **Limitations** This is one market, one courier ecosystem, and an order mix that is 92% cash on delivery. Refusal is close to free for the customer here, which is not true where payment is taken up front. None of these figures should be carried to a card-paid market without repeating the measurement. The model is a ranking tool, not a verdict on an individual order. At 29% precision, most of the orders in the riskiest fifth still arrive and are paid for. Anything built on top of it has to be cheap and non-punitive: a confirmation call, an address check, a different packing decision. Cancelling on the score would destroy more than it saves. The two measurements are also not the same measurement. The model predicts customer refusal. The address study describes orders the courier flagged as undeliverable, from a single day's extraction. They overlap, but they are different failures, and a signal that separates one does not automatically separate the other. Feature importance by gain, finally, is a statement about what the model used, not about cause. ### The right decision, in the wrong order A duplicate-order detector flagged 52 orders as duplicates in one day. Roughly 29 customers still got the same parcel twice, because two automated steps ran in the wrong sequence. **The finding** In one measurement day — 24 hours of live orders — the duplicate-order detector flagged 52 orders as duplicates. Of those, 23 were cancelled. On the other 29, a label had already been printed by the time the cancellation was attempted, and a guard refuses to cancel a parcel that has already been dispatched. So roughly 29 customers received the same parcel twice. The automatic labelling loop runs on its own schedule. It created the label in the gap between the moment the detector decided to cancel and the moment the cancellation reached the order. The guard then did exactly what it exists to do: it refused to cancel a parcel already on its way. Neither component was wrong. The detector decided correctly, the guard behaved correctly, and a dispatched parcel should not be cancelled. The defect was entirely in the sequence. What each of those cases costs is return shipping paid in both directions, and a customer who receives two of the same thing. **The fix** The labelling service now asks the duplicate rule for its verdict on that one order before printing the label. If the verdict is cancel or hold, no label is made. The cancellation itself stays with the detector: there is exactly one rule, with two call sites, rather than two rules free to drift apart. Any exception inside that check lets the parcel ship. That is deliberate: a good order held back by a bug in a safety check costs more than a duplicate that slips through. Before it was switched on, the check was simulated against the 183 orders then waiting for a label. None was wrongly blocked. **Where we were wrong** Our own checker reported an alerting system as dead. It was not. A flag disabling customer-facing email was on, while internal alerts leave on a different path. The check was asking the wrong question: it measured the channel it knew about, not the channel the alerts actually use. The same checker defined a duplicate more loosely than the rule it was checking — same phone and same items at any time, instead of within the rule's own window. Returning customers were counted as duplicates, and the rate it reported was not real. The lesson: a checker greedier than the rule it checks produces false alarms. The cost is not the first false alarm; it is that the second time it cries wolf, nobody reads it. A check has to ask the same question as the thing it checks, in the same terms, or it is not a check. **Limitations** This is one fulfilment system. The counts come from one measurement day: 52 flagged, 23 cancelled, 29 already labelled. The check was validated in one simulation, against the 183 orders waiting for a label at that moment. The sequencing failure is general; the counts are ours. Any system where a decision and its effect are separated by a queue, and where a second automation is free to act in that interval, can produce the same result. The figures above say nothing about how often it happens elsewhere. ### Zero ads is not an answer Searched by domain, an ad transparency archive returned zero ads for three subjects. Their owners were running 17 and 13 ads at the time. Here is the check that works, and the recommendation it killed. **A confident false negative** The check we ran was the obvious one. Ad transparency archives have a search box, so we typed a domain into it. Three subjects came back with zero ads. Zero is a clean result. It looks like evidence rather than the absence of evidence, and we read it that way. It was wrong. At the time of that search, two of the owners behind them were running 17 and 13 ads. The archive was answering a different question from the one we thought we had asked. A false negative of this shape is worse than a missing dataset. A missing dataset announces itself. An empty result that matches what you expected to find gets accepted without argument, and then it gets built on. **Why a domain search cannot find an advertiser** The reasons are structural, and none of them is a bug. First, an advertiser is registered in these archives under the legal name of the company or person paying for the ads. The domain is a property that advertiser happens to own. It is not the key of the record, so it is not what the index is keyed on. Second, an app-install advert does not point at the advertiser's domain at all. Its destination is the app's store listing. Put together: a domain search is looking for something that was never there. The empty result is a correct literal answer and a useless answer to the question actually being asked, which is whether a competitor is buying its traffic. Any question you can only settle through an index has to be asked in that index's key. **Start at the trader disclosure** Step one. Open the app's store listing and read the About the developer disclosure. Under EU rules the trader's legal name and address are published there. That is the key the domain search was missing, and it is public, on the same page as the app you are already looking at. Step two. Search that legal name in the ad transparency centre, filtered to the region you care about. The result gives you an advertiser identifier, the number of ads running, and a customer identifier. Record all of them. The identifiers are what let you come back to the same record later, and what let someone else reproduce your result without repeating your search. At this point, be precise about what you have. You know a specific legal entity is advertising in that region, and how many ads it is running. You do not yet know what those ads are selling. **The destination and the chronology are the proof** Step three. Open one of the creatives and read its destination. The proof is a destination URL pointing at that exact app package, together with the install call-to-action markers. Skip this step and you have only established that a company advertises somewhere, which settles nothing. Step four. Read the chronology: first shown and last shown, per creative. This is where the account stops being a snapshot and becomes a history. Campaign migration is the hardest pattern to fake. Old creatives stop on exactly the day a new package launches, and new campaigns start days later. The alignment of start and stop dates across a set of creatives would have to have been staged before anyone asked. Note the direction of the inference. The destination proves where the money pointed. The chronology proves when it was pointing there. Neither proves how much of it there was. **The recommendation we withdrew** We had recommended entering a category. The case rested on two apps that appeared to be growing organically. Growth with no visible paid acquisition suggests demand pulling a product along, and a category where demand pulls is a category worth entering. We ran the method above on both. Both were running install campaigns across the entire growth window, alongside all of the growth we had been reading as organic. The organic growth we were about to copy did not exist. The recommendation was withdrawn. The uncomfortable part is that we had not misread the evidence, because we had never collected any. The domain search had reported the absence of its own key, and we had recorded that as a finding about the world. **Running the same check** Order matters, and the cheap steps come first. Open the store listing, take the legal name from the trader disclosure, search that name in the transparency archive with the region filter set, then open a creative and read its destination. If you stop before the destination, stop describing the result as proof of anything. When a search under the correct legal name genuinely returns nothing, that is a real result and worth writing down. It is still weaker than a positive: it says you found no ads in that archive, in that region, at that moment. **Limitations** This method works only where both of these exist: a transparency archive for the ad platform, and a trader-disclosure requirement on the store listing. Remove either and the chain breaks. Without the disclosure you have no legal name to search; without the archive you have a name and nowhere to search it. What it proves is bounded. It shows that ads ran and where they pointed. It does not tell you how much was spent. Treating ad counts as a proxy for spend would put back the same kind of unsupported inference this check exists to remove. It is also blind by construction to any channel that publishes no archive at all. A finding of zero ads is a statement about the archives you searched, never about the total advertising a company does. ### The test was green, the effect was absent We switched an old automated system off after confirming the new one produced shipping labels correctly. One module was still in shadow mode, and 114 orders over three days shipped without a gift the customer had already paid for. **A green test and an absent effect** We switched an old automated system off and let a new one take over. Before the switch we checked the most visible thing the old system did: it produced shipping labels. The new system produced them correctly. We recorded the migration as verified. One module in the new system had been left in shadow mode. It wrote a line recording what it would have added to the order, and did nothing else. That module was responsible for adding a small free gift the customer had already paid for as part of the offer. 114 orders over three days shipped without it. The function that decided what to add was correct and it was tested. What was missing was execution. On the same day, the same shape turned up twice more: a routine that only suggested a correction instead of applying it, and a feature marked in a code comment as not yet carried over. **Why testing the code could not catch it** A unit test asks whether a function returns the right answer. Shadow mode does not change the answer. It changes whether anything downstream happens with the answer. From inside the test suite the two questions are indistinguishable. The assertion is on a return value, and the return value was right no matter what happened to it afterwards. Nothing in the suite asked whether a caller took that value and wrote it to an order. Every green test we had was true. It was true about the calculation, not about the world. The gap between a correct answer and an applied answer is invisible to a test that only ever sees the answer. **The assumption we gave up** We assumed a migration could be verified by sampling: pick the most visible effect, confirm it works, generalise. That was refuted the same day. The label path was live; the gift path was not. One green sample said nothing about the others. A subtler assumption did more damage: that a module which exists in the new system is a module that runs. Presence in the repository, resolved imports, a call that is genuinely reached — none of these mean the effect landed. Shadow mode is precisely the state of a module that is present, reached, correct, and inert. What we believe now is narrower. The unit of verification is not the module and not the test. It is the effect on an order. **The rule we adopted: list the actions, not the files** Write down every effect the old system had on an order. Not modules, not files, not services. Effects — the things that would be different for a customer if they stopped happening. For each one, name who performs it after the switch-off. If the answer is "nobody", the migration is not finished, whatever the tests say. This is the step that would have caught our failure, and it takes no tooling. Search the new code for the vocabulary of deferred work: shadow, would, log-only, dry run, "not ported", "apply at cutover". Each hit is a debt that falls due on the day you switch the old system off, and the day of the switch is exactly when nobody is reading code comments. Confirm with the people downstream — the warehouse, the support desk — not only with the database. They handle the physical result. The database answers the question you thought to ask it. **The watchdog, and how it was wrong too** We now run a daily comparison of candidates against effects for every automation: how many orders qualified, and on how many did the effect actually land. It sends mail only when something is dead or has fallen below a threshold, so silence is the normal state and a message means something. Its first version reported a working automation as dead. It was reading a history table that happened to be empty, while the real effect was being written to a different column. The automation was fine. The monitor was not. A monitor can measure the wrong thing exactly as easily as the system it monitors, and it inherits none of the scrutiny the system gets. Confirm that you are measuring the effect before you trust the monitor. A red alert on a healthy path and a green one on a dead path are the same defect wearing different colours. **Running the same check yourself** Take an order that went through the old system and write down what happened to it, end to end, in the language of outcomes rather than components. Then trace each of those outcomes through the new system on a real recent order, in the live store of record — not in a fixture, not in staging with seeded data. Grep the new codebase for the deferred-work vocabulary above. Treat every hit as unpaid work until you can point at what pays it. Reading the comment is not enough; find the code that performs the effect. Then ask the people downstream what they are seeing. Where the parcel and the database disagree, the parcel wins. Our own database was internally consistent throughout — it recorded, correctly, that nothing had been added. **Limitations** This is one migration on one company's systems. It shows that a shadow-mode module can survive a cutover with a full green test suite; it does not establish how often that happens elsewhere, or in what kinds of stack. The count of 114 is what we could attribute with confidence. We did not attempt to reconstruct anything beyond the three days, so the figure is a floor for the window we examined rather than a total. The rule is a checklist, not a proof. Enumerating the effects of an old system is manual work, and it can miss an effect nobody remembers it had. The daily comparison narrows the exposure afterwards, but it does not solve the enumeration problem — and its first version demonstrated that it can be wrong about the very thing it exists to watch. ### A detector that fires on nothing An event trigger set at 2.5 sigma produced 166 events on a device lying still and 151 while something was genuinely happening. Fixing it takes three conditions, not one. **What we measured** We built an event trigger on a z-score with a threshold of 2.5 sigma. On a simulated device lying still and untouched, it produced 166 events in five minutes. When something was genuinely happening, it produced 151. The rest condition scored higher than the real one. The cause is not a tuning error. A z-score is scale-free. It divides the residual by the spread it has just measured. A device at rest has minuscule deviations, but they are still 2.5 sigma relative to themselves. The sensor was talking to itself. This matters beyond one sensor, because the same shape appears wherever an alert is defined as "unusual compared to recent history". Unusual is not the same as large, and a detector that only knows the first will happily report the second when there is nothing there. What follows is the three defects we found, all of them real and all of them present at once. **An absolute floor, in the units of the measurement** The first defect is that a statistical threshold was the only threshold. A z-score answers how unusual a reading is relative to recent noise. It does not answer whether the reading is large. A detector of "something happened" needs both answers, and the second one has to be expressed in physical units. So pair the statistic with a floor: the residual must also exceed a real-world magnitude, well above the hardware's own quantisation step. Below that step you are not measuring the world at all, you are measuring the granularity of the conversion, and the statistics of that granularity are perfectly capable of clearing any scale-free threshold you set. The subtle part is where the two conditions are evaluated. Both must be required on the same channel. If one channel supplies the statistics and another supplies the magnitude, two non-events combine into an event. This is easy to write by accident, and it defeats the floor without looking like it does. **A threshold derived from a false-alarm budget** The second defect is that 2.5 was a guess. It sounds strict. It is not, once you count how many opportunities the noise gets. At 100 samples a second, thirty minutes is 180,000 opportunities. A z-score above 2.5 occurs about 1.2% of the time, which is roughly 2,200 crossings in that window of nothing at all. This is a multiple-comparisons problem, and it has an arithmetic answer. Decide how often you are willing to be wrong, then solve for the threshold: erfc(z / sqrt(2)) = budget / (rate × 3600). At one false alarm every ten hours, that gives 5.14 sigma, not 2.5. Note what the formula contains. The required z depends on the sampling rate. A per-sample probability is not a per-hour probability, and the detector's user experience is measured in hours. **Persistence, because the spread is estimated too** The third defect survives the first two. The spread in the denominator is itself estimated, on a sliding window, so it fluctuates. A residual divided by a fluctuating estimate has heavier tails than a normal distribution, which blows the nominal budget even after you have derived z correctly from it. You can model those tails. The cheaper fix, and the one that does not require getting the model right, is persistence: require several consecutive qualifying samples before emitting an event. This is physically motivated rather than statistical bookkeeping. A real object holds the reading deviated for hundreds of milliseconds, which is tens of samples. A noise spike holds it for one. Together, the three conditions — floor, derived threshold, persistence — are what "something happened" actually means in code. **Where we were wrong: the flaky test** We got the test wrong before we got the detector right. The first version of it passed and failed alternately within the same run, because it used too few seeds. A flaky test on a detector is not a flaky test. It is the detector telling you the threshold is arbitrary: the outcome depended on which noise realisation the run happened to draw. The correction was more seeds. With enough of them, 166 events at rest against 151 during real activity stopped being a surprise and became the expected consequence of dividing by a spread that shrinks with the signal. **Running the same check** Verify the effect, not the code. The analysis function was arithmetically correct throughout; what was wrong was what it did. So the check is behavioural: run a long simulated rest period and count the events it emits. A count that exceeds the active period is the same bug we had. Vary the noise level and the seed, across several values of each. A rest-count that changes with the seed tells you the threshold is arbitrary. Then test the three conditions separately. Confirm the floor sits above the quantisation step in the sensor's own units. Recompute the threshold from your sampling rate and your false-alarm budget rather than inheriting it. And feed a case where the statistics come from one channel and the magnitude from another: if the detector fires on that, the two conditions are not on the same channel. **Limitations** The worked example is one sensor at one sampling rate. The arithmetic of the false-alarm budget is general; the specific thresholds are not. 5.14 sigma is the answer to one budget at one rate, not a constant to copy. The rest condition was simulated. A simulated rest is a model of rest, not real hardware at rest. Persistence assumes the event you care about lasts longer than the noise does. That held here, because a real object holds the reading deviated for hundreds of milliseconds against a spike's single sample. Where it does not hold — short events, or sampling too slow to see them — this fix does not apply and the tails have to be modelled instead. ### The CPA spike was a measurement failure, not a performance failure A store's reported cost per acquisition appeared to jump, so the budget was cut. Reported conversions had fallen from 158 to 44 a day while real orders held at 100 to 110 a day. Performance was fine, the tracking was not, and the budget went back. **The dashboard said the campaign had broken** A store's reported cost per acquisition appeared to jump. Nothing else in the account looked unusual. The budget was cut in response, which is what the number appeared to demand. Every report agreed with every other report. That agreement felt like confirmation. It was not. The reports all drew on the same conversion feed, so they could not have disagreed. Consistency between views of one broken input is not evidence that the input is right. The check that was missing: count the orders the shop actually received, in the shop's own records, over the same days. **What the forensic pass found** Reported conversions collapsed from 158 to 44 a day. Over the same period, real orders held steady at 100 to 110 a day. Two series describe the same events. One more than halved. The other did not move. Only one of them can be right about the world, and it is the one the warehouse fulfils against. The performance was fine. The measurement was broken. The budget was put back. **A ratio breaks from either end** Cost per acquisition is a ratio: spend divided by conversions. The two halves are measured by different systems. Spend is counted by the advertising platform, which counts it carefully because it invoices from it. Conversions are counted by tracking that has to survive a browser, a consent prompt, a redirect and a confirmation page. The denominator is the fragile half. When the denominator falls and the numerator holds, the ratio rises exactly as it would if performance had collapsed. The shape is identical. There is no signature in the curve that separates the two cases. Nothing inside the ad account can tell them apart either, because the account holds only one of the two counts. The answer is always outside the platform that raised the alarm. **Where we were wrong** The budget had already been cut before anyone counted the orders. That is the real mistake in this story, and it is procedural rather than analytical. The forensic work was correct. It simply came second. A cut driven by a broken denominator makes the real business worse while fixing nothing. Orders kept arriving while the spend behind them was reduced. The evidence that the campaign was healthy had been sitting in the shop's database the whole time, unread. We now treat a move in a reported ratio as a question rather than an instruction. Cutting is an action, and an action can wait until both halves have been counted separately. **How to run the same check** Split the ratio and verify each half against the system that owns it. Spend against the advertising platform. Orders against the shop's own database, not against a reporting layer that sits downstream of the same tracking you are trying to test. Put the two daily series side by side over the same window. If orders are flat and reported conversions fell, it is a tracking failure. If orders fell together with conversions, performance really did move and the ratio is telling the truth. Mind the boundaries. Same days, same timezone, and one definition of an order held constant on both sides of the comparison, whether that is orders placed or orders confirmed. Keep the check cheap enough to run before reacting. A costly check will be skipped on exactly the day it matters. **Limitations** This is one store over one period. It shows that a move in cost per acquisition can come from the denominator. It does not show how often that happens, in this account or anywhere else. We are not publishing what broke the tracking. The claim here concerns the diagnosis, not the root cause, and a fix for one tracking fault does not generalise to others. The check separates a tracking failure from a performance failure. It does not separate a tracking failure from an attribution shift, where the orders still arrive and are still counted but are credited to another channel. The order total stays flat in that case too, and telling the two apart needs channel-level work this check does not do. We did not quantify what the period at a reduced budget cost us, and we cannot do so from the counts described here. ### Brand search ads do not add traffic. They replace it. An account suspension gave us a natural experiment across three online stores. Organic click-through collapsed while brand ads ran and recovered above baseline within a week of them stopping — on two of three. The third is why this is a test, not a rule. **What we were looking at** A brand search campaign bids on your own name. The dashboard reports it as one of the best campaigns you have: high click-through, low cost per acquisition, excellent return on ad spend. That is exactly what you would expect if the people clicking were going to arrive anyway. The question is not whether brand ads convert. It is whether they are incremental — whether total traffic goes up when they run, or whether paid clicks simply replace free ones. **The natural experiment** One store's Google Ads account was suspended, which stopped its brand campaign overnight without us choosing the timing. That removes the usual objection to a before/after: nobody picked the moment. While the brand ads were running, organic click-through on the store's own brand queries fell from 57.5% to 35.3% to 22.0% across three weeks. Impressions were stable and average position held at 1.1 — so this was not a ranking loss and not a demand loss. The clicks were going somewhere else: to the ad above the organic result. In the week after the ads stopped — ten paid clicks in total — organic click-through returned to 74.6%, above where it had started. Full recovery in one week. No competitor took the traffic in the meantime, which is the argument brand campaigns are usually defended with. **What it looked like on two more stores** The same signature showed on two others: brand impressions flat, position stable, organic click-through falling as brand spend rose. One went from 79.2% to 19.6%, its organic clicks from 1,749 to 487 a week. The other from 77.2% to 33.8%. We paused the brand campaign on both and watched revenue rather than clicks, because clicks are not the thing being bought. **Where it did not hold** On one of the two, organic revenue rose and covered the loss almost exactly — total search revenue stayed flat while the brand spend went to zero. Two weeks later organic was still 38% above its pre-pause baseline. That one stayed paused. On the other, organic revenue fell and stayed down. Total search revenue dropped by about a third over two weeks. The ads there were incremental — they were bringing people who did not otherwise arrive. We restarted the campaign. That is the finding, and it is the reason this is written as a method rather than a rule. One of the two stores we were confident about was the opposite of what we expected. **A counterexample worth knowing** A fourth store's campaign looked like the same waste by name — it had “Brand” in the title — and was not. Only part of its spend was on the store's own name; the rest was on generic terms and on a competitor's product name. Impression share on its brand terms was 25.5%, with 63% of the loss to rank rather than budget — meaning somebody else was bidding and winning. And its organic brand click-through had never been high to begin with, so there were no free clicks to lose. Brand ads are waste only where you already own the click and nobody is contesting it. Do not judge a campaign by its name. **How to run the test yourself** 1. In Search Console, split queries into brand and non-brand and pull them weekly. 2. Look for the signature: impressions stable or rising, average position stable, organic click-through falling. That combination is cannibalisation. Falling impressions would be a demand problem; a falling position would be a ranking problem. 3. Pause the brand campaign on one account. Not all of them. 4. Measure revenue, paid plus organic together, for at least two weeks. One week is a spike, not a recovery. 5. If organic does not take the traffic back, the ads were incremental. Restart them and say so. **Limitations** Four stores, one market, one language, cash-on-delivery heavy. The suspension was a genuine natural experiment; the three deliberate pauses were not blind and were not randomised. Revenue figures are our own stores' — no client data is involved, and no budgets, margins or spend figures are published here. The percentages above are click-through rates and impression shares as reported by Google Search Console and Google Ads for the weeks described. ### A target CPA does not know what an order is worth We built one profitability engine that counts only delivered orders, net of VAT, down to the individual SKU. When we compared its verdicts with the target-CPA gate we had been running, the verdicts inverted. The formula below is a planning gate, not realised profit. **What we measured** We built one profitability engine. Revenue minus cost of goods, minus shipping, minus marketing, net of VAT, counting only orders that were actually delivered, down to the individual SKU. One place where the arithmetic lives, so that two people asking the same question get the same answer. Then we ran the same campaigns through the gate we had been using until that point: a target cost per acquisition. The verdicts inverted. Campaigns that looked expensive against the target turned out to be the ones worth scaling. Others sat comfortably inside the target and were losing money on every order. The cause is structural, not a calibration problem. A target CPA is a ceiling on what you pay for an order. It carries no information about what that order is worth. Two campaigns at the same cost per order can sit on opposite sides of breakeven if they sell different goods into different places. **Breakeven per placed order** Written out, breakeven per placed order is: average order value divided by one plus VAT, minus cost of goods, minus shipping, all multiplied by the delivered rate, minus shipping multiplied by one minus the delivered rate. The first bracket is what an order contributes when it arrives: the price with VAT taken out, less what the goods cost you and what it costs to move the parcel. Multiplying by the delivered rate is the honest part, because you only collect that contribution on the orders that are actually delivered. The second term is the one most calculators leave out. A refused parcel still costs you the shipping, and in some markets it costs it both ways. A calculator that stops at the first bracket is describing a business in which nothing is ever refused. We keep a scale threshold at 70% of breakeven. The gap is the margin we hold back for error in the inputs we do not control precisely. Under the threshold we add budget. Between the threshold and breakeven we hold. Above breakeven we cut. **Two stores with the same average order value** In a market where a meaningful share of orders is cash on delivery and some fraction of those is refused, the delivered rate is the heaviest term in the formula. It scales the contribution up or down, and it drives the loss term at the same time. Two stores with identical average order value and identical cost of goods can have completely different breakeven points purely because of their delivery rates. Nothing about the product, the price or the buying has to differ. The parcels arriving at different rates is enough. The practical consequence is that a target CPA copied from one store to another, or carried forward from one period into the next, is wrong by construction unless the delivered rate travelled with it. In our experience it rarely does, because the target is usually set once and then treated as a property of the account rather than of the fulfilment. **Where we were wrong** We ran target-CPA gates ourselves before the engine existed. Not as a placeholder we were sceptical about, but as the gate: the number a campaign was judged against when someone had to decide whether to add budget or stop it. They mislabelled profitable campaigns as expensive for as long as we used them, and decisions were made on those labels. We cannot publish the amounts, and the argument does not need them. What was wrong was the sign, not the size: the gate pointed the wrong way on campaigns whose real economics were fine, and it pointed the right way on campaigns that were losing money per order. The reason it went unnoticed for so long is that a target CPA fails quietly. It never returns an error. It returns a verdict, in the right units, with a plausible number attached. The only way to discover it was wrong was to build the thing that computed the alternative and compare the two, campaign by campaign. **Reported return on ad spend is not a substitute** The obvious repair is to stop using cost per order and use the return on ad spend the platform reports instead. That does not work either. The reported figure is inflated by attribution: the platform counts the orders it believes it caused, and it is the party grading its own work. A brand campaign is the clearest case. A large part of the demand it reports was going to arrive anyway, from people who already knew the name and were on their way. The platform records the purchase, the purchase is real, and the causal claim behind it is not. There is a second problem underneath the first. Return on ad spend is revenue over spend, and revenue is not contribution. It is measured before VAT comes out, before the goods are paid for, before the parcel is moved, and before anyone refuses delivery. Those deductions are the terms that decide the verdict. **Running the same check** You need these inputs: average order value, cost of goods per order, shipping per parcel, and the delivered rate. Most of them come out of your own records. The delivered rate is the one people get wrong, so take it from courier or fulfilment data, from the parcels that were actually handed over and paid for, not from the shop's order table, which counts orders placed. Then compute the two terms and compare the result against your cost per placed order, meaning marketing divided by orders placed rather than orders delivered. The denominators have to match. If you divide spend by delivered orders and compare it against a breakeven built on placed orders, the two sides are not measuring the same thing. The same arithmetic runs on our services page, at https://goingstellar.ro/services/. If you build it yourself, keep the inputs visible in the output rather than only the verdict, because when a verdict changes the first useful question is which one moved. **Limitations** The formula assumes marketing is spent per placed order. Where a channel does not behave that way, comparing its cost per order against this breakeven is not a like-for-like comparison, and the gate should not be used to settle it. It also assumes the delivered rate is stable enough to be treated as a constant. It is an average taken over some past period and then applied to orders that have not happened yet. On a small campaign, or a new product, that average can be a poor description of the orders it is being applied to, and the whole verdict moves with it. It does not settle attribution. The gate takes the orders credited to a campaign and asks whether they are worth their cost. Whether those orders belong to that campaign is a separate question, and this arithmetic has nothing to say about it. Finally, this is a planning gate, not realised profit. It tells you whether a campaign is worth more money before the money is spent. What you actually earned comes from the engine, after the parcels have been delivered and the goods, shipping and refusals are known rather than assumed. ### An inactive pixel was not a warning. It was an outage. On cash-on-delivery stores running a third-party checkout, the advertising platforms were receiving no purchase event at all. The only visible symptom was a pixel marked inactive, and we read that as a warning for a long time before we read it as an outage. What follows is the mechanism, the fix, and the check anyone can run. **What we found** On stores that take payment cash on delivery and run their orders through a third-party checkout, the advertising platforms were receiving no purchase event. Not a delayed event. Not one attributed to the wrong campaign. Zero purchase events reached the platform for those orders. The only visible symptom was that one platform showed the tracking pixel as inactive. That is a quiet line in an interface full of advisory notices, and it reads as something to tidy up when there is time. It does not read as the sentence it actually means, which is that the campaign is being optimised on nothing. Nothing else in the stack reports this. Errors get reported. Absences do not. Spend reports fill in, traffic reports fill in, and the conversion column is simply low or empty, which is a shape any account can produce for many ordinary reasons. **Why a missing signal is worse than a broken one** An ad platform with no conversion signal does not stop. It keeps spending and it keeps optimising, towards whatever proxy is available to it. The auction still runs. The budget still empties on schedule. That is what makes the failure expensive rather than merely untidy. The campaign shows every outward sign of working: money going out, traffic coming in, a live dashboard. The one thing missing is the only thing the optimisation was supposed to be steered by. The mechanism is ordinary. The purchase is completed on a checkout we do not own, so the moment that counts as a conversion happens where our browser tag never runs. The shop's own records know about the order. The platform never hears about it. **Where we were wrong** The pixel had been showing as inactive for a long time before anyone treated it as an outage rather than a warning. The symptom was visible the whole time. We had seen it and we had filed it under the wrong heading. This was not a detection failure, which is the comfortable version of the story. It was a triage failure. A field saying that a measurement channel was not live sat in the queue behind things that felt more urgent because they were louder. The rule we apply now is blunt. Any status that says a measurement channel is not receiving data is an outage until someone proves otherwise. Warnings are allowed to wait. Outages are not, and a measurement outage is worse than most, because it silently invalidates every decision taken downstream of it. **What the fix has to do** The fix was server-side. Orders feed the advertising platforms' server-side conversion APIs from our own records, keyed on the real order rather than on a browser session that may never have existed. It has to be idempotent. The same order can be sent more than once, on a retry or on a reconciliation pass, and a duplicated purchase teaches the platform the wrong thing just as effectively as a missing one. Keying on the real order is what makes a second send harmless. It has to be reconciled on a schedule. A server-side feed fails silently in exactly the same way the browser pixel did, unless something counts what was sent against what exists. Fired and forgotten, it reproduces the original bug in a new place, with the added disadvantage that it now looks fixed. **How to run the same check** Count the purchases in the shop's own database for a closed period. Count the purchases the platform says it received for the same period. Compare them. The discipline is in the second source. Do not compare the platform against itself. Reported conversions and attributed revenue inside the same interface come from the same pipeline, so their agreement proves only that the pipeline is consistent with itself, including when it is consistently receiving nothing. Read the result conservatively. Zero at the platform, against a database that plainly holds orders, is the failure described here. A partial gap is ordinary under-reporting, which has many causes, from attribution windows and time-zone boundaries to consent, and it needs a different investigation. Run the check per store: checkout configuration is a property of the store, not of the account. **Limitations** This describes cash-on-delivery stores on a third-party checkout. A standard hosted checkout with a working pixel does not have this problem, although it can still under-report, and under-reporting is a different problem with different remedies. We are not reporting a size. No spend figure, no recovery figure, no claim about how the campaigns would have performed had the signal been present. That counterfactual cannot be measured after the fact and we are not going to estimate it. The check tells you whether purchase events arrive. It does not tell you whether they are attributed correctly once they do, and it does not validate the values attached to them. Those are separate checks. Passing this one is a precondition for them, not a substitute. ### How do Shopify stores in Eastern Europe handle cash on delivery: COD forms, fees and order confirmation Shopify treats cash on delivery as a manual payment method: the order is created unpaid, no money moves through Shopify, and there is no built-in fee or confirmation step. Stores that sell mostly on COD fill those gaps with apps: a COD order form that bypasses checkout, phone confirmation by one-time code, rules that hide COD, and fees added by an app. Refunds for cash orders still happen outside Shopify. **The short answer** On Shopify, cash on delivery (COD) is a manual payment method. The order arrives marked unpaid, the merchant marks it paid after the courier hands over the cash, and Shopify itself moves no money. Shopify offers no native COD fee, no phone confirmation and no way to refund cash. Stores in Eastern Europe, where COD is common, typically add three kinds of app: a COD order form with optional one-time-code phone verification, payment rules that hide COD for risky carts or regions, and an app or workaround that adds a COD fee. This article is for merchants setting up COD on Shopify and for people building tools for them. We checked each platform fact in Shopify's own documentation and each app fact on the app's App Store listing. **How common cash on delivery is** For Romania, the most specific recent figure we could trace comes from an April 2025 payments analysis by The Paypers, which relays data from the Romanian Association of Online Stores (ARMO): between 60% and 65% of e-commerce orders are paid by cash on delivery. That is an industry association estimate relayed by a trade publication, not a measured national statistic, and we found no equivalent figure for the other Eastern European markets that we could check at a primary source. Whatever the exact share, the practical consequence is the same. A store selling on COD carries a cost that card stores do not: the parcel that is refused at the door and comes back. This is usually called RTO, return to origin. Most of the tooling below exists to reduce RTO before the parcel ships. **How cash on delivery works inside Shopify** Shopify's help centre lists cash on delivery among its manual payment methods, alongside money orders and bank transfers. A merchant activates it under the payments settings. The name Cash on Delivery (COD) is reserved by Shopify for the suggested method, so a store cannot create a custom method with that name. Orders paid with a manual method appear as pending on the orders page. After the money is received, the merchant has to mark the order as paid manually, then fulfil it as usual. Shopify states that third-party transaction fees are not charged on manual payments. Two things are missing from this flow by design. There is no step that checks whether the buyer really intends to receive the parcel, and there is no setting that adds a charge when the buyer picks COD. Both gaps are filled by apps. **COD order forms that bypass checkout** The most visible pattern on COD-heavy stores is an order form on the product or cart page that creates the order without the buyer going through Shopify Checkout. The leading example is Releasit COD Form & Upsells, whose listing describes advanced cash on delivery without Shopify Checkout, through a popup or embedded form. On the day we checked, the listing showed a rating of 4.9 from 2,495 reviews and the Built for Shopify badge. Opened from Romania, the listing also says Based in Romania, but that label sits under Popular with stores like yours; the developer address on the listing is in London. The same listing advertises phone verification with a one-time code by SMS or WhatsApp, limiting postal codes, blocking IP addresses, confirmation via SMS or WhatsApp, and custom fees, plus upsells and quantity offers in the form. The form exists to shorten the purchase to a few fields, and the verification exists because a shorter form also makes fake orders easier. **Confirming COD orders before they ship** Confirmation means getting a signal from the buyer, after the order and before the parcel leaves, that the order is real. The common mechanism is a one-time code (OTP) sent to the buyer's phone. COD King, a Built for Shopify app whose listing showed 4.9 from 971 reviews when we checked, describes verifying COD orders via SMS or WhatsApp to reduce fake orders. The same listing shows the other levers merchants use against RTO: partial COD payments, where the buyer pays an advance online to secure the order; COD fees to nudge buyers toward prepaying; discounts to convert COD orders to prepaid; and rules that show or hide COD by location, cart value or product. Each one trades a little conversion for fewer refused parcels, and the right mix depends on how often a store's parcels actually come back. **Hiding COD with payment customizations** Shopify's supported way to change which payment methods a buyer sees is a payment customization, built on Shopify Functions. According to the developer documentation, a payment customization function can rename, reorder and hide payment methods, and a store can activate at most 25 payment customization functions. Setting payment terms and hiding specific placements are limited to Shopify Plus. Shopify's help centre states that stores on the Basic plan or higher can install public apps that customize payment methods at checkout, while custom apps built with Shopify Functions are available only on Shopify Plus. In practice, a store not on Plus uses an App Store app to hide COD. Apps such as Kip: Sort Hide Payment Methods describe rules that hide, rename or reorder methods by customer country or location, shipping method, cart total, cart weight, products and customer tags, including hiding cash on delivery in chosen countries. The documentation we read does not describe payment customizations adding a fee. **COD fees: there is no native setting** Shopify's payment settings do not offer a charge tied to the payment method. In a Shopify Community thread on adding a COD fee, a community member and a COD-app vendor both answered that it is not possible to add a fee to cash on delivery directly from Shopify. The vendor's answer carries a commercial interest; the community member's does not, and nothing we read in Shopify's documentation contradicts it. Merchants use two workarounds. The first is a separate shipping rate labelled as COD with the fee built in. The weakness, pointed out in the same thread, is that nothing stops a buyer from picking that rate and then paying by card, or picking the card rate and then choosing COD. The second is an app that adds a fixed or percentage fee to COD orders, which is offered by the COD form and confirmation apps above. **Refunds and couriers: what stays outside Shopify** Shopify's refund documentation describes refunding to the original payment method, to store credit, or to both, and notes that some payment methods cannot be refunded to the original method. It does not describe a refund flow for cash on delivery, and because no money moved through Shopify for a COD order, there is nothing for Shopify to send back. A COD refund is therefore paid outside Shopify, usually by bank transfer, and recorded in the admin. Store credit is the one documented route that stays inside Shopify. Courier integration for pickup points and labels comes from courier apps. For Packeta, for example, the App Store lists third-party apps that add a pickup-point map to checkout, print labels from the admin and, on some plans, track COD payments. **Where the reports were wrong** 1. The COD share. The report put COD at about half of Romanian orders. The figure we could trace, ARMO data relayed in April 2025, is 60% to 65%. Both are estimates; the report's was lower and unsourced. 2. No Shopify app for Packeta. The report said Packeta had no Shopify app. The App Store lists third-party apps that connect stores to Packeta, with pickup points at checkout and label printing. It may be true that Packeta itself publishes none; the integration exists. 3. Stale listing figures. The report gave COD King a 5.0 rating from about 900 reviews and Releasit 2,458 reviews. The listings showed 4.9 and 971 for COD King and 2,495 for Releasit when we checked. Listing numbers move weekly; quote them with a date or not at all. 4. Claims of absence. The report stated that no app on the App Store collects bank details for COD refunds, and that Shopify's own return labels cover only the United States. We could not confirm either at a primary source. Absence is hard to prove in a store with thousands of apps, so both stay out of the findings above. **Method** We started from an AI deep-research report on cash-on-delivery apps for Eastern European Shopify stores. It was written by an AI research engine and mixed listing data, market estimates and a product pitch. We removed the pitch and the pricing analysis, picked the platform and app facts a merchant would act on, and checked each on 11 October 2026: Shopify's help centre pages on manual payments, checkout customization and refunds, Shopify's developer documentation on payment customization functions, the App Store listings of the apps named, a Shopify Community thread on COD fees, and The Paypers analysis for the market share. We could not verify: the number of Romanian stores using each app, the claim that one courier's official app requires a higher Shopify plan, ratings for several smaller courier and risk-scoring apps, and the absence claims listed above. Those were dropped. **Limitations** The only market share we could trace is for Romania, and it is an association estimate, not an official statistic; the title's wider region rests on the same mechanics applying, not on measured shares elsewhere. App listings describe what a vendor says the app does, not whether it works on a given store. The COD fee finding rests on a community thread and the absence of a setting in Shopify's documentation, not on a Shopify statement that the feature does not exist. Shopify's checkout and plan rules change, so plan availability should be checked again before building on it. ### Can you create YouTube in-stream campaigns through the Google Ads API? What Demand Gen lets you automate Classic Video campaigns cannot be created or updated through the Google Ads API; it only reads and reports on them. The documented route to an API-built YouTube in-stream campaign is a Demand Gen campaign whose ad groups select only the in-stream channel. We checked an AI research report on this against Google's API documentation on 11 October 2026 and list what it got right, what it missed and what had changed. **The short answer** No, not as a classic Video campaign. Google's developer documentation says: “You cannot create new Video campaigns or update existing ones using the Google Ads API.” The API mainly fetches and reports on existing Video campaigns, ad groups, ads and criteria. Google points to two alternatives: Google Ads scripts, or Demand Gen campaigns, which the API supports fully for creation and management. Demand Gen is a campaign type that serves image and video ads on Google-owned surfaces such as YouTube, Discover, Gmail and Maps, and on Display. A Demand Gen ad group can be limited to a single surface. Setting only youtube_in_stream to true in the ad group's channel controls gives a campaign that serves YouTube in-stream ads and is built entirely through the API. One nuance the AI report missed: since API version 24, VideoResponsiveAdInfo objects inside Video campaigns are mutable, where earlier they were not. So the Video campaign is read-only, but not every object inside it is. **Which operations each resource allows** Every mutate operation in the Google Ads API is one of create, update or remove, and an update changes only the fields named in its update_mask. The v25 reference pages show which of the three each resource allows, and the report got these right. Asset: create and update, no remove. Assets are permanent; you stop using one by removing or replacing the ads that reference it. Audience: create and update, no remove. UserList: create, update and remove. CampaignConversionGoal and ConversionGoalCampaignConfig: update only, because Google creates them for you. CampaignBudget, Campaign, AdGroup and AdGroupAd allow all three for Demand Gen. A campaign's channel type cannot change after creation, so turning a campaign into another type means building a new one. **How to build an in-stream-only Demand Gen campaign** 1. Create everything in one request. Google's Demand Gen guide recommends creating the budget, campaign, ad group, assets and ad atomically in a single GoogleAdsService.Mutate call, using negative temporary IDs so that later operations can reference earlier ones. Either all entities are created or none, so a failure leaves no orphaned budget or empty campaign. 2. The budget must not be shared. A Demand Gen campaign cannot use a shared budget. The trap: the CampaignBudget reference says explicitly_shared defaults to true when it is not set on create, so it has to be set to false explicitly. A daily budget uses amount_micros and a total budget uses total_amount_micros; only one of the two may be set. One million micros equal one unit of the account currency, and monthly spend on a daily budget is capped at 30.4 times the daily amount. 3. Campaign type without a subtype. Set the channel type to DEMAND_GEN and leave the subtype unset. Set the EU political advertising declaration contains_eu_political_advertising: a new campaign without it fails with FieldError.REQUIRED. 4. One of four bidding strategies. Demand Gen supports Maximize Clicks, Target CPA, Maximize Conversions and Target ROAS. Target CPC for Demand Gen arrived in version 22, set on the campaign and overridable per ad group. For Target CPA, Google recommends a daily budget of at least 15 times the expected target CPA. 5. The ad group carries the channel controls. Create the ad group without an ad group type. Channel controls live on the ad group, under demand_gen_ad_group_settings.channel_controls, and take either a channel_strategy (ALL_CHANNELS, or ALL_OWNED_AND_OPERATED_CHANNELS, which switches off third-party channels such as Display) or selected_channels, seven booleans: youtube_in_stream, youtube_in_feed, youtube_shorts, discover, gmail, display and maps. At least one must be true. For in-stream only, set youtube_in_stream and nothing else. 6. Decide audience mode at creation. audience_setting.use_audience_grouped is immutable. If the ad group will target a reusable Audience resource, it must be set to true when the ad group is created; it cannot be switched on later. 7. The ad. Demand Gen ad groups accept multi-asset, carousel, video responsive and product ads. For a video responsive ad, videos (YouTube video assets) and logo_images are required on create and on update. Carousel ads need between 2 and 10 cards. 8. Create it paused. Create the campaign with status paused, read it back, and enable it only after the ads have passed policy review. **Testing before production** Send each mutate first with validate_only set to true: the API checks the request without applying it. Note that BatchJobService does not support validate_only, always runs with partial failure, and rejects operation types that do not support partial failure, including CampaignConversionGoalOperation. Test accounts appear as cancelled in the Google Ads interface, have no billing and do not serve ads. They need their own hierarchy under a test manager account, and they cannot interact with production accounts. They test the structure of requests, not delivery, policy review or bidding. Daily operation limits depend on access level: Explorer allows 2,880 operations a day against production accounts and 15,000 against test accounts, Basic allows 15,000, and Standard is unlimited. A single mutate request takes at most 10,000 operations, and so does a single AddBatchJobOperations request. Rate limits return RESOURCE_EXHAUSTED; the client libraries raise the maximum response size to 64 MB. **Reporting on in-stream delivery** Demand Gen supports campaign, ad and asset reporting through GoogleAdsService.Search and SearchStream. Campaign clicks are filtered by click type CROSS_NETWORK. The segment ad_sub_network_type, added in version 23, separates Demand Gen YouTube delivery and must be selected together with ad_network_type. Version 25 added ad_sub_format_type, which breaks down non-skippable in-stream ads by duration. Ads shown in the interface as “Demand Gen video ad (legacy)” are not supported by the API and are not returned by search queries. An account that still runs them will show less in an API report than in the interface. **Version history that matters here** Version 22 (15 October 2025) added Target CPC for Demand Gen and capped batch requests at 10,000 operations. Version 23 (28 January 2026) added ad_sub_network_type and made sharing one ad across ad groups return AD_SHARING_NOT_ALLOWED. Version 23.1 (25 February 2026) added YouTubeVideoUploadService, supported only over REST and in the Python client library. Version 24 (22 April 2026) made videos and logo_images required for video responsive ads and added a view-through conversion optimisation flag that defaults to false. Version 24.2 (24 June 2026) added Maps as a selectable channel. Version 25 was released on 22 July 2026, and its minor versions 25.1 and 25.2 followed on 19 August and 23 September 2026. From 1 April 2026, an account with even one campaign lacking the EU political advertising declaration has every campaign-management mutate rejected with EU_POLITICAL_ADVERTISING_DECLARATION_REQUIRED. Reporting still works. An update that sets only the declaration is allowed, which is the way out. **Where the report was wrong** Developer tokens. The report said every request needs a developer token, that the token comes from a manager account's API Center, and that OAuth does not replace it. Google sunset developer tokens on 9 September 2026. Access levels moved to the Google Cloud project that owns the OAuth credentials, based on activity in the 90 days before the sunset; the header is now optional and ignored; and new developers sign up in the Google Cloud Console without needing a manager account. In version 25, a Cloud project with only test access calling a production account gets CLOUD_PROJECT_NOT_APPROVED_FOR_PRODUCTION. Google's own call-structure page still lists the developer token among required headers, so the documentation disagrees with itself. “Nothing in a Video campaign is mutable.” True for the campaign, false for video responsive ads since version 24. Omissions that cost time. The report did not mention that explicitly_shared defaults to true, that a whole account is blocked from campaign mutates from 1 April 2026 if one campaign lacks the EU declaration, the budget guidance of 15 times target CPA, or the limits of BatchJobService. Heuristics presented beside documentation. The report's scaling steps and decision thresholds were operating rules of thumb, not Google guidance. It said so in places, but they sat in the same tables as verified API facts. We left them out. **Method** We started from an AI deep-research report, written in Romanian, on automating Demand Gen and YouTube in-stream through the Google Ads API. On 11 October 2026 we checked its claims about resources, operations, fields, limits and versions against the Google Ads API developer guides, the v25 reference pages and the release notes. We kept a claim only where Google's page said the same thing that day. We could not verify, and dropped: several error codes the report listed (among them BUDGET_BELOW_DAILY_MINIMUM and two total-budget errors), its asset size tables beyond the fields quoted here, its notes on placement criteria in Demand Gen, and its sample code, which we did not run. **Limitations** This is a documentation check, not a test against a live account. Documentation and API behaviour can differ, and the developer token pages show that Google's pages can disagree with each other. The API changes every quarter. Everything here describes version 25 as of 11 October 2026; a later major version may change field requirements again, as version 24 did for video responsive ads. We did not cover delivery quality, costs or creative performance of in-stream ads. The question here is only what can be automated. ### Which EU rules change how online stores sell in 2025–2027? A dated checklist Between December 2024 and mid-2027, EU law adds product-safety details to every online listing, bans generic green claims, puts a duty on cheap imported parcels, starts the packaging regulation and the battery passport, and phases in national e-invoicing. We started from two AI deep-research reports, kept only the dates we could confirm at the source, and list the ones the reports got wrong. **The short answer** For an online store selling into the EU, the dates that matter are: 13 December 2024 (product safety information on every online offer), 28 June 2025 (accessibility of e-commerce services), 1 July 2026 (a per-item customs duty on low-value imported parcels), 2 August 2026 (AI disclosure duties), 12 August 2026 (the packaging regulation starts to apply), 27 September 2026 (ban on generic environmental claims), 30 December 2026 and 30 June 2027 (deforestation due diligence), and 18 February 2027 (battery passport). National e-invoicing mandates run alongside, from 2025 to 2027. This checklist is desk research: we read AI-generated research reports, checked each date at the official text or the authority's own page on 11 October 2026, and dropped what we could not confirm. It is not legal advice, and each rule has scope conditions that decide whether it reaches a particular store. **Already in force: what every product page should show** General Product Safety Regulation (GPSR). Regulation (EU) 2023/988 applies from 13 December 2024. Its Article 19 says an online or other distance offer must clearly and visibly show at least four things: the manufacturer's name, registered trade name or trademark and contact address; for a manufacturer outside the EU, the name and contact details of the responsible person in the EU; information that identifies the product, including a picture, its type and any other identifier; and any warning or safety information in a language consumers can easily understand. For a store, the GPSR is a data problem: those fields have to exist per product before they can be displayed. European Accessibility Act (EAA). Directive (EU) 2019/882 applies to e-commerce services from 28 June 2025. Microenterprises providing services are outside its scope. The detail of what it requires is the subject of a separate article on accessibility and price rules, so we only record the date here. Price-reduction rule (Omnibus). When a store announces a price reduction, Article 6a of the Price Indication Directive requires the prior price shown to be the lowest price the trader applied during at least the last 30 days before the reduction. In 2024 the EU Court of Justice held, in case C-330/23, that a percentage reduction must be calculated from that prior price. The same separate article covers the practical side. **2026: imports, AI, packaging and green claims** Low-value parcels, 1 July 2026. The Commission states that from 1 July 2026 a temporary €3 customs duty applies to low-value parcels imported from outside the EU, for goods worth up to €150. The duty is charged per item by tariff classification, not by quantity: the Commission's own example is that five T-shirts pay one duty, while three T-shirts and a watch pay two. A store that ships from outside the EU into the EU has to price this in at the line level, not the parcel level. AI transparency, 2 August 2026. Article 50 of the AI Act applies from 2 August 2026, according to the Commission's FAQ. A chatbot on a store must make clear to the visitor that they are dealing with an AI system, unless that is already obvious, at the start of the first interaction. The duty to mark AI-generated content in a machine-readable way has a short grace period only for systems already on the market before 2 August 2026: their providers must comply by 2 December 2026. Separately, the AI Omnibus entered into force on 27 July 2026 and moved the high-risk rules to 2 December 2027 (Annex III) and 2 August 2028 (Annex I). Most store use of AI is not high-risk; the disclosure duty is the part that reaches storefronts. Packaging, 12 August 2026. The Packaging and Packaging Waste Regulation, Regulation (EU) 2025/40, entered into force on 11 February 2025 and applies from 12 August 2026. The Commission's own note on that date is clear that most of the visible obligations come later: a harmonised sorting label from 2028, and limits on empty space, restrictions on some single-use plastic packaging, reuse targets, recycled content and recyclability from 2030. What changed on 12 August 2026 includes the restriction on PFAS in food-contact packaging. Producer registration and fees for packaging remain national schemes. Green claims, 27 September 2026. Directive (EU) 2024/825 on empowering consumers for the green transition had to be transposed by 27 March 2026 and applies from 27 September 2026. It adds practices that are banned in all circumstances, including: a generic environmental claim such as "eco-friendly" without proof of recognised excellent environmental performance; a sustainability label not based on a certification scheme or set up by public authorities; a claim about a whole product when it covers only one aspect; a claim that a product is neutral or reduced in impact because of emissions offsetting; and presenting a legal requirement as a distinctive feature of the offer. For a store, this means an audit of product copy and badges, not a new feature. **Late 2026 to 2027: deforestation and the battery passport** Deforestation regulation (EUDR). After the amendment adopted in December 2025, the Commission states that the obligations apply from 30 December 2026 for large and medium operators and for micro and small operators already covered by the EU Timber Regulation, and from 30 June 2027 for other micro and small operators. The rule concerns specific commodities and products made from them; a store selling such goods needs supplier due-diligence evidence, which a storefront cannot produce on its own. Battery passport, 18 February 2027. Article 77 of the Batteries Regulation, Regulation (EU) 2023/1542, requires a battery passport from 18 February 2027 for each light-means-of-transport battery, each industrial battery above 2 kWh and each electric-vehicle battery placed on the market. Ordinary portable batteries in consumer gadgets are not in that list. **National e-invoicing that EU stores meet when they sell to businesses** E-invoicing is set nationally, and the dates differ. Germany: every domestic business must be able to receive an e-invoice from 1 January 2025; issuers may keep using other invoices until 31 December 2026, or until the end of 2027 if their prior-year turnover was at most 800,000 euro, according to the federal finance ministry's FAQ. Belgium: structured B2B e-invoicing between VAT-liable enterprises has applied since 1 January 2026. Poland: KSeF became mandatory on 1 February 2026 for large businesses and on 1 April 2026 for the rest, with businesses invoicing up to 10,000 PLN a month joining on 1 January 2027. France: the tax authority gives 1 September 2026 as the start of the obligations through approved platforms. Spain: VERI*FACTU invoicing software must be adapted before 1 January 2027 for corporate taxpayers and before 1 July 2027 for everyone else. Most of these mandates cover invoices between businesses. A store that sells only to consumers is affected mainly through its ability to receive supplier invoices, not through its own sales. **What did not become law** The proposed Green Claims Directive, which would have required prior verification of environmental claims, is not a rule to plan for. The Commission announced on 20 June 2025 that it intended to withdraw the proposal. We could not find a published record of the formal withdrawal decision, so its legal status is unresolved; either way, there is no adopted text. The enacted directive from the previous section is the one that applies. **Method** We started from two AI deep-research reports produced by research engines, one on EU regulation as a source of e-commerce product demand and one on platform and compliance deadlines. We treated every claim in them as unverified. On 11 October 2026 we checked each date we kept against the regulation text on EUR-Lex, a Commission page, or the national authority's own site. Where only a secondary source confirmed a date, we either named that or left the claim out. We dropped the reports' market estimates, app counts and review counts: they change weekly and were not the point. We could not confirm at a primary source the Romanian e-invoicing deadlines and penalties, the claim that product identifiers become mandatory in import declarations from 1 November 2026, or the French issuing date for small and medium firms, so none of them is stated here as fact. **Where the reports were wrong** The €150 exemption was not abolished on 1 July 2026. One report said the exemption "was abolished" and replaced by the duty. The Commission describes a temporary €3 duty per item, by tariff classification, on goods up to €150, pending a permanent reform. The report also missed the per-classification rule, which is what changes the price of a mixed parcel. Poland's penalty holiday may be longer. A report said there were no sanctions for errors in 2026. On 16 September 2026 the Polish tax information service announced a plan to extend the deferral of penalties to 31 December 2027. The plan needs a change in the law and had not been enacted when we checked. Germany did not make issuing e-invoices mandatory on 1 January 2025. One report dated the German mandate to that day. Only the duty to receive started then; the duty to issue follows the transition periods above. The AI Act's broader rules did not all start on 2 August 2026. One report said most of the remaining rules applied from that date. The AI Omnibus moved the high-risk obligations to December 2027 and August 2028; the transparency duties for chatbots kept the August 2026 date. Packaging is not mainly a 2026 problem. The reports treated 12 August 2026 as the moment packaging obligations bite. Most of what changes a store's packaging, the label and the empty-space limits, starts in 2028 and 2030. **Limitations** This is a list of dates, not an analysis of scope. Each rule has conditions, such as product categories, company size or whether the buyer is a business, that decide whether it applies to a given store. National transposition of the directives can add detail that we did not check country by country. Dates can still move: three of the rules above were changed or delayed after adoption. We checked every date on 11 October 2026 and will not see later amendments unless we check again. ### The European Accessibility Act and online stores: what it requires and who is exempt Since 28 June 2025 the European Accessibility Act requires consumer-facing online stores in the EU to be accessible to people with disabilities. Microenterprises that provide services are exempt, but the test is narrower than most summaries say: fewer than 10 persons and a turnover or balance sheet not exceeding 2 million euro. We checked the claims in an AI research report against the directive text and found the threshold misstated. **The short answer** The European Accessibility Act, Directive (EU) 2019/882, requires an online store that sells to consumers in the EU to make its website and app accessible to people with disabilities from 28 June 2025. A store run by a microenterprise, defined as an enterprise that employs fewer than 10 persons and has an annual turnover or an annual balance sheet total not exceeding 2 million euro, is exempt from the accessibility requirements for services. Everyone else selling online to consumers must meet the requirements, publish information on how the store meets them, and correct non-conformities. Penalties are set by each Member State. In Romania the directive was transposed by Law 232 of 19 July 2022. **Why online stores are covered** The European Accessibility Act lists the services it covers in Article 2(2). Point (f) of that list is e-commerce services, which Article 3(30) defines as services provided at a distance, through websites and mobile device-based services, by electronic means and at the individual request of a consumer with a view to concluding a consumer contract. A web shop or a shopping app aimed at consumers fits that definition directly. Recital 43 of the directive adds that the e-commerce obligations apply to the online sale of any product or service. An online store is therefore in scope because of how it sells, not because of what it sells. A shop selling garden furniture is covered in the same way as a shop selling e-readers. The directive is aimed at business-to-consumer selling. A purely business-to-business shop does not meet the definition, because the definition requires a consumer contract. **What the European Accessibility Act requires of an online store** Annex I of the directive sets the accessibility requirements. For websites and mobile apps the core requirement is that they are made accessible in a consistent and adequate way by making them perceivable, operable, understandable and robust. These four principles are the same ones used by the Web Content Accessibility Guidelines (WCAG) and by the EU Web Accessibility Directive for public sector sites. Annex I, Section IV, point (g) adds three requirements specific to e-commerce services: providing the information on the accessibility of the products and services being sold, when the responsible economic operator provides it; ensuring the accessibility of identification, security and payment functionality when delivered as part of a service; and providing identification methods, electronic signatures and payment services that are perceivable, operable, understandable and robust. In practice this puts login, checkout and payment at the centre of the obligation. Article 13 sets the duties of service providers. A store must prepare information explaining how its service meets the accessibility requirements and make it available to the public in written and oral format, in a way that is itself accessible. Annex V says this information goes into the general terms and conditions or an equivalent document, and should include a description of the service, how it operates, and how the requirements of Annex I are met. This is the legal basis for the accessibility statement many stores now publish. Article 13 also requires procedures that keep the service conforming as it changes, corrective measures when it does not conform, and, where the service is not compliant, informing the competent national authorities with details of the non-compliance and the corrective measures taken. **Who is exempt, and what the exemption does not cover** Article 4(5) states that microenterprises providing services are exempt from complying with the accessibility requirements and from any obligations relating to compliance with those requirements. An online store is a service under the directive, so a microenterprise running an online store is outside these duties. The definition in Article 3(23) has two parts, and both matter. The enterprise must employ fewer than 10 persons. And it must have an annual turnover not exceeding 2 million euro or an annual balance sheet total not exceeding 2 million euro. A business with 8 people and a turnover above the cap can still be a microenterprise if its balance sheet stays under the cap. The exemption for services is not an exemption for products. For microenterprises dealing with products, Article 14(4) only removes the requirement to document a disproportionate-burden assessment, and Article 14(8) removes the duty to notify authorities. A small business that manufactures, imports or distributes a product covered in its own right by the directive, such as an e-reader, still has product obligations. Larger stores have a separate escape route that is often misunderstood. Under Article 14, the requirements apply only to the extent that compliance does not fundamentally alter the service or impose a disproportionate burden. A store relying on this must document its assessment against the criteria in Annex VI, keep the results for five years, renew the assessment at least every five years for services, and inform the authorities. It cannot rely on it if it received funding for improving accessibility. **Dates, transition and what is excluded from scope** Member States had to transpose the directive by 28 June 2022 and apply it from 28 June 2025. Article 32 allows service contracts agreed before 28 June 2025 to continue without alteration until they expire, but for no longer than five years from that date, and sets a transitional period ending on 28 June 2030 for services provided using products already lawfully used before that date. Article 2(4) excludes some website content: pre-recorded time-based media and office file formats published before 28 June 2025, certain online maps, archived content, and third-party content that is neither funded, developed by, nor under the control of the economic operator. Content a store commissions or controls, including its theme and its checkout, is not third-party content in that sense. **Which standard to test against** Article 15 gives a presumption of conformity to services that follow harmonised standards whose references are published in the Official Journal of the EU. The European standard for digital accessibility is EN 301 549. ETSI published version 4.1.1 on 2 September 2026, which moves the web requirements to WCAG 2.2 Level AA and adds material mapping the standard to the European Accessibility Act. On 11 October 2026 we could not confirm that any version of EN 301 549 has been cited in the Official Journal under the European Accessibility Act. Until a citation is published, following the standard is good evidence of effort but does not by itself give the legal presumption. Testing against WCAG 2.2 Level AA is the practical target either way. **What enforcement looks like so far** The Dutch consumer authority, ACM, published results on 24 March 2026 from tests on about 100 of the largest Dutch online stores and the sites of the largest telecom and energy providers. ACM found that at 61% of the largest online stores a customer using assistive technology could not place an order, for example because the order button could not be reached by keyboard or the CAPTCHA was inaccessible. A further 33% had serious problems. ACM said companies that do not improve enough risk enforcement action. Accessibility overlays are not a shortcut. In April 2025 the US Federal Trade Commission approved a final order against the widget vendor accessiBe, after alleging that its product did not make all user websites WCAG-compliant. The order bars the company from claiming its automated products make any website compliant unless it has evidence. The case is American, but the finding about what a script can and cannot fix applies anywhere. **Method** We started from an AI deep-research report on web accessibility and the European Accessibility Act. We read it in full, selected the claims that matter to a store owner, and checked each one on 11 October 2026 against the directive text on EUR-Lex, the ETSI publication record, the Dutch regulator's announcement, the US FTC press release and the EU AccessibleEU record of the Romanian law. Only claims we could confirm at those sources are kept. We dropped the report's market and pricing material, its figures on vendor revenue and lawsuits, its company-size statistics and its French enforcement details. The French regulator's pages refused automated access, so we could not confirm the fine amounts and inspection counts the report gave. **Where the report was wrong** The microenterprise threshold was misstated. The report described the exemption as fewer than 10 employees and turnover below 2 million euro, and elsewhere as applying to providers below both thresholds. The directive uses persons, not employees, and the financial test is turnover or balance sheet, each not exceeding 2 million euro. The report then advised targeting stores that exceed either threshold, which would wrongly include some microenterprises. It relied on regulators' summaries instead of the law. The Dutch regulator describes covered businesses as having 10 or more employees and/or turnover above 2 million euro. Read literally, that short summary omits the balance-sheet alternative. The directive text is the reference. It treated the exemption as general. The report did not say that the exemption covers microenterprises providing services only. Microenterprises dealing with products get lighter paperwork, not an exemption. It left out the parts a store needs to act on: the accessibility information required in the terms and conditions, the transitional rules, the disproportionate-burden procedure and the status of the harmonised standard. **Limitations** This is a reading of the directive, not legal advice. National transpositions can add detail, including penalties, authorities and how the size test is calculated, and we checked only the existence and date of the Romanian law, not its text. We did not verify whether EN 301 549 has been cited in the Official Journal since our check, and that status may change soon after publication. We report one regulator's test results; they describe the largest Dutch stores, not stores in general. ### Do feature-request votes predict app installs? What a public backlog told us Votes on a platform's public feature-request tracker did not predict installs. Across the ten ideas that passed a first filter, 4,339 votes had turned into about 440 installs spread over about 40 competing apps: roughly one install per hundred votes. The ticket's status carried more signal than its vote count, and the status was the part most often misread. **Votes are not demand** Votes on a public feature-request tracker do not predict how many people will install an app that solves the request. In our measurement on one software marketplace, 4,339 votes across the ten most promising requests had produced about 440 installs, spread over about 40 competing apps. That is roughly one install per hundred votes, and most of those installs were on free tiers. A public feature-request tracker is the backlog where a platform's own users file requests and vote for them. It is public, searchable and sorted by votes, which makes it look like a ready-made demand survey. A vote costs the voter one click. An install, and even more a payment, costs them a decision. The two measure different things: a vote records annoyance, an install records a budget. **Why we checked** On 4 September 2026 two AI deep-research reports recommended the same thing: stop scanning the marketplace for gaps and hunt in the platform owner's public backlog instead, where unmet demand is supposedly written down and counted. We took the claim seriously enough to test it at the source rather than adopt or dismiss it. We checked 14 tickets the reports relied on. For each one we read the ticket itself (status, dates, the platform's own comments) and then looked up the installs of the marketplace apps that already address it. Installs come from the marketplace's public listing data, so the check is reproducible by anyone. **Three tickets, three conversions** The first ticket had 617 votes. An app that does exactly what it asks for exists, has shipped 13 versions in 16 months and carries a 2.5-star rating. It had 9 installs. The second had 102 votes. The app that answers it was listed precisely in the window when the platform's new API limits were hurting users most, and had shipped 50 versions. It had 14 installs. The third is the best conversion we found, and it still makes the point. A request with 547 votes, collected over 19 years, is addressed by two competing apps with 149 and 98 installs: about 247 installs in total, most of them on the free tier. Even the outlier turns a vote into an install that pays nothing. Read together, a request with several hundred votes implies a ceiling of tens of installs, at a price anchored near zero. The vote count told us the problem was real. It told us nothing about whether anyone would pay to have it solved. **The status is the signal, and the status is what gets misread** On a feature-request tracker the status field carries more information than the vote count. We ranked the statuses by what they imply for someone who wants to build an app. Strong: Won't Fix or Not Being Considered, with the platform on record that it will not build the feature. Only 2 of the 14 tickets were here. Weak: Gathering Interest, which on this tracker is the label for a ticket nobody has triaged yet. 7 of the 14 were here. Poison: Under Consideration, In Progress or Closed, Fixed, where you would be building what the platform owner is already building. 4 of the 10 proposals in the reports rested on tickets like that. The clearest case was the reports' first recommendation. It was described as a ticket left open and ignored since 2015. The ticket was in fact created in 2013, had been closed as a duplicate in 2024, and the feature had shipped natively in 2025 under another ticket with 2,102 votes, closed as fixed. The recommendation was to build something the platform had already given away. **We enumerated the whole graveyard** The tracker's search works anonymously, so the set of refused requests can be listed completely rather than sampled. Above 200 votes, the complete set of tickets the platform had refused was 38. The Gathering Interest label covered 340 tickets above the same threshold, but because it means untriaged, we treated it as noise. 28 of the 38 died at once for platform reasons: the request needed the native editor's page structure, a change to the data model, or an external integration that an app cannot provide. The remaining 10 went through the installs check. None survived. Those 10 requests had 4,339 votes between them and about 440 installs spread across about 40 competing apps. The installs check is also the fastest filter we have. Applied to the reports' original proposals it removed 6 of 10 in minutes, on the one question that matters before writing code: does anybody pay? **The page that measures demand also destroys it** On 5 of the 10 surviving requests, the platform itself publishes a free workaround, a knowledge-base article, or direct links to competing apps in the ticket description. On one ticket this is measurable: the platform points 494 voters to a third-party app that has 4 installs. The vote count did not even convert when the platform sent the voters to a solution by hand. High-vote threads are also where competitors advertise. A vendor with 329 installs posts its own product in the comments of one popular ticket; another app promotes itself in the comments of the 617-vote ticket. Whoever finds the ticket meets an incumbent before their own listing exists. Someone had already built the most promising of the ten ideas, from exactly the most promising ticket, and named the app after the problem. It had 1 install. Ten vendors working on one of the ideas had 16, 9, 7, 3, 3, 2, 2, 1, 1 and 0 installs. The largest base any exact clone of any of the ten ideas had reached was 16 installs. **An empty niche under a popular request is a wall, not a gap** When a request has many votes and nobody serves it, the tempting reading is that the niche is free. Our data points the other way. A governance vendor with 6,119 installs has sold into the exact area for a decade and never closed a 559-vote request that is 20 years old. Two query-extension vendors with 3,950 and 923 installs both ship custom query functions, and neither offers the ordering that a popular request asks for. Large, competent vendors sitting next to an unserved request usually means the platform does not allow the thing, or that customers will not pay for it. The useful question is what stops them, not why has nobody noticed. **Where we were wrong** We nearly took the AI reports at face value, because the numbers in them were exact. The vote counts were right in 13 of 13 cases. The status, the date or the platform's own position was wrong in 4 of 14, and every one of those errors ran in the direction that made the proposal look better. The reports were precise where precision was cheap and wrong where being wrong helped the argument. In the same audit, 4 of 10 proposals depended on a platform mechanism that does not exist: a module, a schema field, an event or an allowed endpoint that the platform's documentation does not provide. None of that shows up in a vote count. We also kept running the backlog search after its answer was in. The tracker is public and finite, and every vendor on the platform reads the same list. A request old enough and popular enough to look like a signal has usually been everyone's shopping list for years, with the free workaround printed underneath. **How to use a public backlog without being misled** 1. Use it for hypotheses only. A tracker tells you which problems users complain about. It does not size a market. 2. Filter by status first. Keep the requests the platform has refused on the record. Drop untriaged ones, and drop anything under consideration, in progress or fixed. 3. Check the platform's own text. Read the ticket description for workarounds, knowledge-base links and links to competitors. 4. Use installs as the veto. Before any code, look up the installs of every app that already addresses the request. If exact clones sit at single-digit installs, the request is not a market. 5. Ask what stops the incumbents. A big vendor next to an unserved request is evidence of a wall. **Limitations** This is one platform's tracker and one marketplace, measured on 4 September 2026. The conversion figure is an aggregate over about 40 apps; individual requests ranged from single-digit installs to the two-app outlier above. Votes accumulate over years while installs are a snapshot, so the ratio compares different time windows. Public install counts include free tiers and trials, so they overstate paying customers rather than understate them, which makes our conclusion conservative. We only enumerated requests above 200 votes. A different threshold, or a tracker on another platform, could produce a different ratio, though we would expect the direction to hold. ### How small B2B software companies actually got their first paying customers We checked eleven founder accounts at their sources. The first paying customers came from people the founder already knew, from problem threads the founder answered by hand, or from one launch inside a concentrated community. Only one came from a marketplace listing, and that founder had nearly a decade of experience in the ecosystem. **The short answer** Small B2B software companies rarely got their first paying customers by being discovered. In the founder accounts we could verify, customer number one came from one of three places: people the founder already knew, people the founder found while they were publicly describing the problem, or a single launch inside a community where the buyers already gathered. We checked eleven cases at their sources on 11 October 2026. In one of them the first sale came from a marketplace listing, within days of launch. That founder had spent nearly a decade as head of technology at an e-commerce agency before building for the same ecosystem. A first paying customer here means the first person or company that paid for the product, not the first sign-up or install. **Founders who started with almost no audience** Simple Analytics. The founder had around 60 Twitter followers before launch. A Product Hunt launch in September 2018 was followed by a Hacker News post that reached the top spot within 5 minutes and stayed there for at least 9 hours. The founder writes that this launch produced the first 30 paying customers and took the follower count to around 800. After that he built a dashboard of Twitter mentions and search keywords and answered relevant conversations by hand. Tally. The two founders write that they did not have a large network of their own. They showed the first version to friends and family in August 2020, then scanned Product Hunt and Twitter and made lists of hundreds of prospects, asking each for feedback, often without success. The people who answered became an early community: their Slack group grew to 400 people. When they launched on Product Hunt in March 2021 they already had 1,500 users, and the launch roughly doubled that to 3,000. At one year they reported 11,000 users and 200 paying users, with nothing spent on marketing. Browserless. The founder describes his first customers as early testers he met while commenting on GitHub issues and helping people on Stack Overflow. Posting in niche places such as Indie Hackers, web-development subreddits and high-visibility GitHub issues then brought a slow stream of customers. He calls the approach unscalable, and it worked. Plausible Analytics. The company's own account says early marketing was all about Indie Hackers, and that the first visitors and customers came directly from there. The public beta started in January 2019; the first paying subscriber arrived on 14 May 2019. **When the hidden advantage is the acquisition mechanism** Several stories that are retold as "built it, launched it" have a precursor that did the real work. Storemapper grew out of freelance work for Shopify merchants. The founder sent the rough first version to everyone he had worked with in e-commerce, and five people signed up. Canny came after its founders grew an earlier free feedback product, Product Pains, to thousands of users; businesses using it asked for what became the paid product, which launched on Product Hunt in March 2017. WideBundle, a Shopify app, is the clearest sequence. The founder read problems in Shopify Facebook groups, posted a Photoshop mockup in a French group and got over 100 comments, then messaged every commenter. After about two weeks of building, only 10–12 signed up. Real traction began after August 2020, when the app launched on the Shopify App Store and with influencers at the same time. Before that, from 2017, the founder had built three Shopify apps that failed. Zigpoll is the case that cuts the other way. Its first sale happened within days of launch, from a merchant who found it on the Shopify App Store, and the founder says the App Store accounts for about 90% of revenue. He had also spent nearly a decade as head of technology at an e-commerce agency. A marketplace can produce customer number one; in the evidence we found, it did so for someone who already knew the ecosystem from the inside. **What did not produce the first customers** Paid search, in month one. ScreenshotOne's founder published his first marketing month: 2,144 paid clicks and 0 paying customers. The first paying customer arrived on 4 July 2022 and was a Twitter acquaintance building a directory of creator tools. The second, later, did come from Google ads, so the lesson is about sequence: ads bought traffic before there was a product and positioning that converted. A marketplace version on its own. Bannerbear's founder had built 8 products before, launched major features on Product Hunt three times and grown a newsletter to 1,500 subscribers. His write-up of the first customers says he also built a Shopify version, which was a failure, and shut it down. No single tactic produced his first 25 customers; at the time of writing he had around 40. **A pattern another founder can run** 1. Find people who are already describing the problem. GitHub issues, Q&A sites, community threads, Facebook groups, competitor reviews. Record the evidence next to each name. 2. Answer first. The useful reply solves the problem even if the product sentence is removed. Stack Overflow's own guidance allows mentioning your product when it is relevant, but requires you to disclose the affiliation, and treats repeated promotion as spam. Other communities have similar or stricter rules; read them before posting. 3. Ask for feedback before asking for money, then ask for money. One small LinkedIn tool's founder sent 80 direct messages in one week to people complaining in agency-owner groups; 12 replied and 8 used the product. Check your own country's rules on unsolicited commercial messages before copying the direct-message part. 4. Treat a marketplace listing as stage two. WideBundle and Storemapper reached the marketplace with users they had found by hand. In our sample, a listing amplified demand that already existed more often than it created it. **Method** We started from two AI deep-research reports on how small software companies reached their first customers. Those reports were written by AI research engines and are not evidence on their own. We picked the claims that matter to a reader, the case stories and their numbers, and checked each one on 11 October 2026 against the founder's own post, the company's blog, or an interview with the founder. Only what we could read at the source is kept here. Every figure above is founder-reported. None of these companies publishes audited accounts, and we did not use revenue figures at all. **Where the reports were wrong** A count that the source does not give. The reports said Browserless's first 10 customers came from GitHub, Stack Overflow and Reddit. The founder's account speaks of "a few" early testers and gives no number. A first customer described as a channel. The reports said ScreenshotOne's first payment came "from Twitter". The founder says it was a Twitter friend with a directory of creator tools, which is a personal connection, and that the second customer came from Google ads. Numbers that differ between accounts. For WideBundle the reports gave about 15 early users and about 25 merchants before the App Store. The founder's longer interview says 10–12 signed up and that the App Store launch coincided with influencer promotion. Details we could not find at all. A follower count for NotionForms, a named earlier app for Zigpoll, a paid-conversion count in the LinkedIn case, and the claim that one Shopify app's installs were organic. We dropped all four. For Storemapper the reports said a couple of dozen clients; the interview we read says five people signed up. **Limitations** Eleven stories are a small, self-selected sample: founders who succeeded write retrospectives, and founders who never got a customer mostly do not. Retrospectives compress months into a sentence and tend to credit the most memorable channel. Some pages we wanted were behind a login or blocked our fetch, so a few claims rest on the company's own blog rather than the original forum post. The sample says what worked for these founders; it cannot give a rate at which any channel works. ### What is different about Google Ads for an online store in Romania, and what does Google's documentation actually say? We took an AI deep-research report on Google Ads for Romanian e-commerce and checked its claims at Google's own help pages on 11 October 2026. The account mechanics are the same as anywhere else; what differs is European: Shopping ads go through a Comparison Shopping Service, EEA consent signals need two extra parameters, and several 2026 deadlines in the report have already passed. **The short answer** Google Ads works the same way for a Romanian online store as for one in any other country: the same campaign types, the same Smart Bidding, the same Merchant Center feed. What is different comes from Romania being in the European Economic Area (EEA): Shopping ads and free product listings in Romania must be placed through a Comparison Shopping Service (CSS), consent mode for EEA traffic has to carry two additional consent signals, and campaigns need an EU political advertising declaration. A Comparison Shopping Service is a company that bids for Shopping ad space on behalf of merchants. Google Shopping is itself one CSS among many. Romania is on Google's list of 21 countries where a CSS must be used. The rest of this article lists the claims from an AI-written report that we could confirm at Google's own documentation, the ones that were out of date, and the ones we dropped because we could not find a primary source. **What is actually specific to Romania** Shopping ads go through a CSS. Google's Merchant Center help says Comparison Shopping Services must be used to place Shopping ads and free product listings in 21 countries, and Romania is one of them. A merchant can send product data to any CSS, including Google Shopping, and can use several at once. Google states that Google Shopping bids like any other CSS and, because the European Commission requires it to be independently profitable in the EEA, deducts a fixed percentage margin from each merchant bid. For a Romanian store this means the choice of CSS is a real decision with a cost attached, not a technical detail. The AI report did not mention CSSs at all. Consent mode needs four signals, not two. Google's tag documentation says that, as part of enforcing its EU user consent policy, consent mode users must send ad_user_data (consent for sending advertising-related user data to Google) and ad_personalization (consent for personalised advertising) in addition to ad_storage and analytics_storage. Google Ads help distinguishes basic consent mode, where tags are blocked until the visitor answers the banner and nothing is sent, from advanced consent mode, where tags load and send cookieless pings before consent. Which one a store may use is a legal question for the store, not a setting to copy. Campaigns carry an EU political advertising declaration. Every campaign has a self-declaration of whether it contains EU political advertising. For anyone creating campaigns through the Google Ads API, Google's developer policy page says a new campaign without the field fails with FieldError.REQUIRED. One thing we could not settle: VAT in the feed. Google's Merchant Center price page tells merchants in 42 named countries to submit prices including VAT, and tells merchants in the United States and Canada to exclude tax. Romania is not on the named list. Romanian consumer prices are normally shown with VAT, and the same page requires the feed price to match the landing page, so in practice the feed carries the VAT-inclusive price. We say this as an inference, because the page does not name Romania. **The 2026 changes a store should know** Target-based bidding on budget-limited campaigns. Google's FAQ says that from 17 August 2026, campaigns marked “Limited by budget” that use Target CPA, Target ROAS or (for Demand Gen only) Target CPC optimise more consistently toward the stated target, where before they could beat it. The rollout was completed on 27 August 2026. It covers Search, Shopping, Performance Max, Demand Gen and Travel campaigns. Google does not change targets or budgets for you; it recommends updating targets to match recent performance, and does not calculate recommended targets for campaigns with fewer than 7 conversions. The practical consequence: a loose target that a campaign used to beat is now the number the campaign aims for. Names of Smart Bidding strategies. From June 2026 the interface shows “Target CPA” instead of “Maximize conversions with a Target CPA” and “Target ROAS” instead of “Maximize conversion value with a Target ROAS”. Google's developer blog says the underlying bidding behaviour is exactly the same. Dashboards that filter on the old labels need to accept both. Product videos in the feed. Merchant Center's 2026 specification update added an optional video_link attribute on 14 April 2026; submitted videos became eligible to serve, and to receive policy and quality checks, on 30 June 2026. A video error stops the video from serving but does not affect the product offer. The same update announces that from 31 January 2027 product images must be at least 500x500 pixels. Content API for Shopping is gone. Google's developer page says the Content API for Shopping reached its sunset on 18 August 2026, that requests without an extension began failing intermittently with HTTP 410 from 1 September 2026, and that all endpoints shut down in early 2027. Integrations must move to the Merchant API. Feeds uploaded by file or scheduled fetch are not API integrations. Enhanced conversions as one setting. Google Ads help says that from April 2026 user-provided data is accepted from website tags, Data Manager and API connections under a single setting, and existing users are migrated automatically. **Claims from the report that held up** Performance Max and Search. Performance Max reaches YouTube, Display, Search, Discover, Gmail and Maps from one campaign, and Google describes it as a complement to keyword-based Search. A Search campaign whose exact match keyword is identical to the query is prioritised over Performance Max; Performance Max may still serve on such terms when the Search campaign is limited by budget. Search themes. Up to 50 search themes per asset group. Google says they have the same priority as phrase match and broad match keywords in Search campaigns. Negative keywords in Performance Max. Campaign-level negative keywords in Performance Max apply to Search and Shopping inventory only; they do not block Display, YouTube or Discover placements. Custom labels. There are five, custom_label_0 to custom_label_4. Shoppers never see them. Each label accepts values of up to 100 characters and up to 1,000 unique values across the account. Margin band, best-seller status and season are the usual uses, because Google sees revenue but not cost. Responsive search ads. Up to 15 headlines of 30 characters and 4 descriptions of 90 characters. Measurement. Conversions imported from Google Analytics goals are not supported for enhanced conversions; Google recommends a Google Ads conversion action set up with the Google tag or Tag Manager, and the tag diagnostics report appears about 72 hours after implementation. Data-driven attribution has no hard minimum, but Google recommends at least 200 conversions and 2,000 ad interactions within 30 days for precise results. In Google Analytics 4, first click, linear, time decay and position-based attribution have been unavailable since November 2023. Seasonality adjustments. Google recommends them for short events of 1 to 7 days and warns they work less well beyond 14 days, because Smart Bidding already handles normal seasonality. The Performance Max uplift figure. Google's retailer guide says advertisers who moved from Standard Shopping to Performance Max saw on average 25% more conversion value at a similar ROAS. That is Google's own average, and Google itself recommends confirming it with an experiment. **A formula worth keeping** Breakeven ROAS is the return on ad spend at which advertising leaves zero contribution: breakeven ROAS = 1 ÷ contribution margin before advertising. Contribution margin before advertising is revenue minus cost of goods, shipping, payment and other variable costs, divided by revenue. A store keeping 30% of revenue before ads breaks even at a ROAS of about 3.33. This is arithmetic, not a Google benchmark, and it is the reason a single Target ROAS across products with very different margins misprices most of them. **Where the report was wrong** Deadlines written in the future that are now in the past. The report told readers to review budget-limited campaigns “before 17 August”. The change shipped and finished rolling out on 27 August 2026. A tool we could not find at the source. The report named a “Bid Target Adjustment Tool”. Google's FAQ on the change does not mention it and points to the bid simulator instead. Trade press describes such a tool; we did not find it on a Google page and left it out. Missing the Content API sunset. The report described the Merchant API as the successor to the Content API for Shopping but did not say the old API was switched off in August 2026. For a store with a custom feed integration, that is the most urgent item of the year. Missing the European specifics. A report titled for Romania did not mention Comparison Shopping Services, which is the clearest way Shopping ads in Romania differ from Shopping ads in the United States. Benchmarks without a usable source. The report quoted average click-through and conversion rates from US-centred industry studies and Romanian market-size estimates from a platform vendor. We did not verify them at a primary source and do not repeat them. A conversion rate benchmark from another market and another definition of “conversion” is not a target. **Method** We started from an AI deep-research report written in Romanian about Google Ads for e-commerce in Romania. We picked the claims that a store owner would act on: rules, limits, deadlines and platform behaviour. On 11 October 2026 we checked each one at Google Ads Help, Merchant Center Help, Google Analytics Help, the Google tag documentation and the Google Ads developer documentation. Claims are kept here only if a Google page said the same thing on that day. We could not verify, and therefore dropped: the report's industry benchmarks, its Romanian market estimates, the claim that Final URL expansion is on by default in Performance Max, the mobile page-speed statistic attributed to Google, the 30-day window for enhanced conversions impact, and its budget-split percentages, which the report itself labelled as a planning framework. **Limitations** Google's help pages change without version numbers, and some pages we read contradicted each other on whether a change had already happened. Everything here is as of 11 October 2026. This is not legal advice. Consent mode, the EU user consent policy and VAT display are legal obligations of the store, and the documentation describes Google's requirements, not the store's full legal position. We did not test any of these rules in a live account for this article. Verified here means “Google's documentation says so”, which is not the same as “the account behaves so”. ### Does the app name or the tagline decide marketplace search rank? On the Atlassian Marketplace, our listings ranked in the top 20 for a query when the app name contained it, and below 170 when only the tagline did. Renaming moved three listings up within hours, and lengthening a name that already matched cost one listing fourteen places. Rank followed the name. **The name decides, the tagline barely counts** On the Atlassian Marketplace, public search rank followed the app's name, not its tagline. Where the name contained the search phrase, our listings ranked between 3rd and 15th. Where the phrase appeared only in the tagline, the same kind of listing ranked 179th or 185th. Lengthening a name that already matched the phrase made its rank worse. A tagline is the short line under an app's name on a marketplace listing. Most listing guides treat the tagline as the place for keywords. Our measurement says that on this marketplace the tagline is indexed far more weakly than the name, so a keyword placed only there does almost nothing for discovery. **Why we measured it** On 11 October 2026 we checked our four public Atlassian listings at the source. Each had exactly 1 install, which was our own test instance, and 0 reviews. The listings themselves worked: the public page offered a free tier and an install button. So whatever was going wrong was happening before anyone reached the page, in discovery, meaning whether the listing appears when a buyer searches for the job it does. Install counts with no reviews and no outside installs at all are the signature of a listing nobody finds. Before changing anything, we wanted to know where the four listings actually sat in search. **Method** We queried the marketplace's public search with the same filters a buyer would apply on the site (the Jira product, cloud hosting), recorded the position of each of our listings, and recorded the total number of results for the query. Install counts came from the public record of each listing. Every number below can be reproduced by anyone with the same query. For each listing we chose the phrase a buyer would type for its job, then noted whether that phrase appeared in the app name, the tagline, or both. **The baseline: four queries, two patterns** planning poker: our listing ranked 185th out of 876 results. The phrase was in the tagline, not in the name, which was SprintEstimate. issue templates: 179th out of 2,794 results. Again the phrase was in the tagline; the name was Templatize. recurring issues: 15th out of 2,821 results. The name was Recurring Issues. epic roll-up: 3rd out of 337 results. The name was Epic Roll-up. The size of the result set did not explain the gap: the two listings that ranked well faced 2,821 and 337 results, the two that ranked badly faced 876 and 2,794. The only variable that split the four cleanly was whether the name contained the query. One more detail points the same way. On the epic roll-up query, our listing with 1 install sat 3rd, ahead of a competitor with 2,799 installs. On that query, matching the text of the name outweighed a very large difference in installs. **The intervention: rename all four** Listing names on this marketplace can be edited on a live listing without going back through review. We renamed all four so that each name carried the phrase a buyer types: Templatize: Issue Templates for Jira, SprintEstimate: Planning Poker & Story Points for Jira, Recurring Issues for Jira: Scheduled Recurring Tasks and Epic Roll-up for Jira: Epic Progress & Story Points. Confirming the edit was its own small trap. The public listing record is served through a cache, and after saving it still showed the old name and the old modification date. The honest proof was the vendor console reloaded from scratch, with the save button greyed out because nothing remained to save. A check that reads the cached public record would have reported the rename as failed. **The first measurement after renaming** We measured again on the same day. The search index was still in transition: the public record showed the new name on only 2 of the 4 listings. So the numbers below are a first reading, not a final one. Where the old name did not contain the phrase, rank rose sharply. planning poker went from 185th to 70th. story point estimation went from 67th to 31st. issue templates went from 179th to 85th. Two broader queries also improved: epic progress from 43rd to 26th, and recurring tasks from 19th to 11th. Where the old name already matched the phrase exactly, rank fell. recurring issues went from 15th to 25th. epic roll-up went from 3rd to 17th: the short name that matched perfectly had been replaced by a long one that still contained the phrase, and the listing lost fourteen places on its best query. **Why a longer name can lose** Many search engines score a match with a formula of the BM25 family, which normalises a term match by the length of the field it was found in. A query word inside a short name counts for more than the same word inside a long one. If the marketplace's search behaves like that, both halves of our result follow: adding the phrase to a name that lacked it helps a great deal, and padding a name that already matched dilutes it. We have not seen the marketplace document its ranking formula, so this is our explanation of the pattern, not a confirmed mechanism. The rule we take from it does not depend on the explanation: lengthen a name only when it does not contain the target phrase; if it already does, keep it short. **Where we were wrong** We had put the target phrases in the taglines on the assumption that the tagline was indexed like the name. It was not, and from the day they went public in September, two of four listings sat below 170th on the phrase that describes exactly what they do. We then over-corrected. Renaming all four in one pass, including the two names that already matched, cost epic roll-up its 3rd place. Changing only the listings that needed it, one at a time, would have shown the dilution effect without paying for it on our best-ranked listing. We had also written down earlier that the marketplace's public REST interface was gone. The public paths answered normally during this measurement. What had gone were the console and vendor paths, which is a narrower statement than the one we had recorded. **What a better rank is worth** Rank is not installs. On a crowded query like planning poker, with 876 results and competitors holding between 400 and 3,500 installs, even 70th place is unlikely to bring anyone. A name change alone will not lift a listing into the top ten there. Thin niches behave differently. On epic roll-up the leading listings have between 17 and 24 installs, so a place in the top five is realistic and is where any discovery that exists actually happens. For a small listing, the useful target is the exact phrase of a narrow job, carried in a short name, rather than the broad query everyone competes for. **Limitations** This is four listings on one marketplace, measured before and a few hours after a rename, with the index still half updated. Some of the movement may be noise in the index rather than the rename, and we have not yet repeated the measurement after a full reindex; we planned to repeat it 24 to 48 hours later before reverting anything. We measured rank, not installs. A better position is a precondition for discovery, not proof of it, and we have no evidence yet that any of the four listings gained an outside install. Other marketplaces weigh names, taglines and descriptions differently, so the rule should be tested on each one rather than assumed. ### Do app marketplaces bring customers by themselves? What we learned publishing on four with no audience They do not. We submitted apps to Shopify, Google Workspace, Atlassian and monday.com without an audience of our own. Listings went live, people installed some of them, and paying customers stayed close to zero. Each marketplace failed in a different place, and the platforms' own rules explain part of it. **The short answer** An app marketplace does not bring paying customers by itself. A listing is a shelf: it lets a buyer who is already searching find and pay for an app, but it does not create that buyer. We published apps on Shopify, Google Workspace and Atlassian, and submitted one to monday.com, with no audience, list or community of our own. On 11 October 2026 we had 39 apps live out of 61 built and, across all four marketplaces, paying customers had stayed close to zero. The failure was not the same everywhere. On Shopify our apps were partly invisible because of our own settings. On Atlassian the review gate held most of them for weeks. On Google Workspace people did install, but almost nobody wanted the paid tier. On monday.com the submission was rejected. This article sets out what we measured on each, what the platforms' published rules say, and what we would do differently. **What we put on the shelves** As of 11 October 2026: 20 apps live on the Shopify App Store, 15 add-ons live on the Google Workspace Marketplace, 4 apps public on the Atlassian Marketplace out of 25 submitted, and 0 on monday.com, where our one app was submitted on 16 August and rejected on 28 September. We had no mailing list, no community and no advertising budget behind any of them. The question we were testing was whether marketplace search alone would bring paying users. **Shopify: invisible by our own switch, then visited by testers** In early September we found that a large part of our approved, published Shopify apps were set to unlisted. Shopify's documentation is explicit that only a listed app appears in category pages, App Store search and third-party search results; an unlisted app can be installed only from its listing URL. The public directory showed only a fraction of our apps; after we switched the setting, all of them appeared. The same audit found apps with no public pricing plan configured, so their cards read "Free" and a merchant had nothing to click to pay. Fixing visibility brought installs, not customers. The outside installs we saw followed one pattern: install, cycle through the paid plans in test mode within minutes, uninstall with the reason "Testing multiple apps". Few installs were real, fewer stayed, and when we checked on 20 September the only active subscription on an installed store was a test charge. On Shopify, the paying side stayed empty. For context, our own scan of the Shopify App Store found that the median app has 0 reviews and 52% of listed apps show no sign of a single customer. Being one of them is the base rate, not bad luck. **Atlassian: a review gate, then the name** We submitted 25 Forge apps. By the end of August, most of them had been rejected because review timed out, and a few for a real security defect, which we fixed. Most of the delay came from a rule we had not read in advance: Atlassian's Forge documentation says that once an app is submitted for listing, or carries a licence in its manifest, it can no longer be shared through an installation link. The reviewer could not install the apps from the listing either, because that is only possible after approval. The documented workaround is an unlicensed copy of each app, which we built for all of them. The first app was approved on 8 September. By 11 October the public apps had no installs beyond our own and no reviews. They were findable only where the app name contained the search phrase: near the top for a query that matched the name, far down the results for one where the phrase was only in the tagline. We describe that measurement in a separate research note. Paying customers had not appeared there either. **Google Workspace: installs that are not people, and people who do not pay** Google Workspace was the only marketplace that sent real traffic. It also taught us to distrust the public number. Google's documentation separates domain installs, seat installs (users who have the app installed and enabled) and individual end user installs (users who installed it directly). When an administrator installs an add-on for a whole organisation, every user in it becomes a seat, so a single domain install can add an entire company to the public figure while the number of people who chose the app themselves stays much smaller. On our own add-ons, most of the public figure turned out to be administrator installs, not people. The traffic came from inside the marketplace. Almost all sessions on our busiest add-on were direct, meaning the marketplace and the editor's add-on picker, and web search sent next to nothing. The listing itself converted well: most visitors pressed Install, though a noticeable share of them abandoned at Google's consent screen. Payment is where it stopped. Very few people across the whole fleet ever reached a pricing page, and among the many who had installed an add-on themselves, only a handful touched the licence screen. Paying customers stayed close to zero. The payment path works end to end; what is missing is people who want what the paid tier offers. **monday.com: the policy decided before the market did** monday.com's developer documentation says the review team no longer accepts apps that copy the core functionality of an existing marketplace app, and rejects apps whose main purpose is integrating with a third-party product that already has a marketplace integration. It names Google tools, Microsoft tools, WhatsApp, Shopify, Zendesk, Calendly, Zoom and Telegram, and says apps built mainly with no-code platforms or AI-generated code are not eligible. Since July 2024 new apps must also use monday's own billing. Our scan of all 1,281 monday.com apps on 16 August showed why discovery there is hard for a newcomer. Apps created in 2025: 288, median 62 installs, 3 above 2,000. Apps created in 2026: 235, median 5 installs, 0 above 2,000. Install counts are a stock, so younger apps have had less time; even so, the curve is falling while new supply keeps arriving. **Where the report was wrong** We read an AI deep-research report that ranked marketplaces for a small studio with no audience. It put monday.com first and suggested building a competitor to a popular Google Sheets integration there, on the strength of its public install count and weak rating. monday.com's own policy names Google tools among the integrations it will reject. The report did not mention that policy. The same report treated public install counts as evidence of demand. On Google Workspace the public figure counts seats, so one administrator can add an entire company; on our own add-ons, the public number was mostly administrator installs rather than people. The report's closing warning was right, though: the riskiest assumption for a newcomer is whether marketplace search shows a new listing to strangers at all. **Where we were wrong** We treated publishing as distribution. Building another app felt like progress because it was measurable; whether anyone would find it was not measured until weeks later. A large part of our Shopify fleet sat unlisted, and nothing in our process looked. We submitted 25 Atlassian apps at once, and all of them hit the same gate together. One pilot would have found the install-link rule in a day. We read a public install number as people, and ranked our own add-ons by it for weeks. And twice, when revenue was zero, we went looking for a broken payment path. The path worked. The number that answered the question, how many people even looked at the paid tier, took seconds to read once we thought to read it. **What we would do instead** Read the platform's submission and duplication policy before choosing an idea. Pilot one app through review before submitting a fleet. Check, from outside, that each listing appears in the marketplace's own search for the phrase a buyer would type. Measure the funnel in the order money flows: listing visits, installs by individuals, people who see the price, people who pay. And find the first buyers by hand, where they already describe the problem, so the listing has someone to amplify. The companion study on how small B2B companies got their first customers shows that the founders who succeeded mostly did exactly that. **Method and limitations** The observations about our apps come from our own measurements between August and October 2026: marketplace consoles, partner event logs, public search queries and our own databases. We describe our own results qualitatively and publish no counts of our installs, users, visitors or customers; the catalogue counts are the ones shown publicly. The platform rules were checked at their official documentation on 11 October 2026. We started from an AI deep-research report on marketplaces and kept only claims we could confirm at the source. This is one small studio's experience. Our apps were mostly utilities in crowded categories, and our listings were written to pass review before they were written to be found, so a stronger product or better listings could do better. The observation window is short: the Atlassian apps had been public for about a month. We report no money figures. ### The EU Omnibus price-reduction rule: the 30-day lowest price, explained for online stores When an online store in the EU announces a price reduction, it must show the lowest price it charged for that product in at least the 30 days before the reduction, and any percentage must be calculated from that price. We checked an AI research report against Article 6a of Directive 98/6/EC, the European Commission's guidance and the 2024 Aldi Süd judgment, and kept only what a merchant needs. **The short answer** Under Article 6a of Directive 98/6/EC, added by the Omnibus Directive (EU) 2019/2161, any announcement of a price reduction must indicate the prior price: the lowest price the trader applied during a period of at least 30 days before the reduction. The rule has applied across the EU since 28 May 2022. In September 2024 the Court of Justice of the EU confirmed in case C-330/23, Aldi Süd, that the prior price is not just a figure to display. A percentage reduction, or a promotional claim stressing an advantageous price, must be calculated from that lowest 30-day price. **What counts as a price reduction announcement** The European Commission's guidance on Article 6a, published in the Official Journal on 29 December 2021 as notice 2021/C 526/02, lists the forms an announcement can take: a percentage such as 20% off, a fixed amount off, a new price shown next to a crossed-out former price, and other techniques that tell the consumer the price is lower, including presenting the current price as a starting price with a higher price to come. Wording that only creates the impression of a reduction is also covered. The guidance names sales prices, special offers and Black Friday offers as subject to Article 6a, so the prior price has to be shown for the goods concerned. Some promotions are outside Article 6a but still subject to the Unfair Commercial Practices Directive: general claims such as best or lowest prices, comparisons with other traders' prices or the manufacturer's recommended retail price, and tied offers such as buy one, get two, or 30% off when buying three. Ordinary price changes that are not announced as reductions are not covered either. **How to calculate the prior price** 1. Look back at least 30 days. The trader must find the lowest price it charged for the product during at least the 30 days before the reduction starts. The guidance is explicit that this lowest price includes any earlier reduced price in that window. Ignoring a promotion that ended a week ago breaks the rule. 2. Base the percentage on that price. The guidance gives an example: if the announcement is 50% off and the lowest price in the previous 30 days was 100, the seller must present 100 as the prior price, even if the last selling price was 160. The Aldi Süd case shows the trap in practice. A brochure showed bananas at 1.29 euro with 1.69 crossed out and a 23% reduction, while the small print said the lowest price in the last 30 days was 1.29. The Court held that the reduction must be calculated from the lowest 30-day price. 3. Keep it for the campaign. The prior price is set at the start of each reduction and can stay the same for the whole campaign, even one longer than 30 days. Article 6a does not require the store to say how long the prior price was applied. 4. Treat each sales channel separately. If a trader sells at different prices in different shops or online channels, the prior price for each channel is the lowest price applied in that channel. A store may also show other reference prices, such as its regular price outside promotions, provided they are clearly explained and do not distract from the prior price. A store may use a longer look-back than 30 days if the price it shows is the lowest in that longer period. **Where the prior price must appear** For a general announcement such as 20% off everything today, the guidance says the prior price does not have to be on the banner. It must be shown for each product at the point of sale, which online means the price section of the product in the shop interface. If the store has not raised the price and has not run another promotion in the past 30 days, the current displayed price is already the prior price and nothing needs to change on the product page. If either has happened, the store must adjust the displayed prior price for the affected products. The Price Indication Directive also requires prices to be unambiguous, easily identifiable and clearly legible. A prior price that the shopper cannot easily find does not meet that standard. **Codes, loyalty programmes, marketplaces and services** Discount codes. A code offered to potentially all visitors, such as 20% off today with a published code, is treated as an announcement and needs the prior price. A reduction for loyalty members only is also covered when the programme is used by many or most customers. Genuinely personal reductions are outside Article 6a: a voucher received after a purchase for the next order, a reduction for a customer's birthday or for signing up to a loyalty scheme, and loyalty programmes that give a continuous discount over a long period. These remain subject to the Unfair Commercial Practices Directive. Marketplaces. Article 6a applies to the seller who contracts with the consumer, including sellers on online marketplaces. The marketplace itself is not responsible unless it is the seller. Traders outside the EU that direct sales to EU consumers are covered. Services and digital content. At EU level the Price Indication Directive applies to goods only. The guidance states that Article 6a does not apply to services, including digital services, or to digital content. National laws can differ. **Where national rules differ: perishables, new products, progressive reductions** Article 6a lets each Member State set its own rules in three situations: goods that deteriorate or expire rapidly, products on the market for less than 30 days, and reductions that are progressively increased during one campaign. A store selling across borders needs the rule of each country it sells into. The guidance limits these options. Perishable goods means items such as fresh food, not seasonal clothing that only expires commercially. The new-products option allows a shorter period, not a complete exemption. A product returning after being out of stock is not a new arrival. Successive campaigns such as Black Friday followed by Cyber Monday do not count as one progressive reduction: the earlier promotional price becomes the prior price for the next one. Romania, as one checked example, set its rule in Article 4^1 of Government Decision 947/2000, inserted by Government Decision 686/2022. The prior price is the lowest price in the same sales area in the last 30 days. For products that deteriorate or expire rapidly the period is 10 days. For products on sale for less than 30 days it is the lowest price in that shorter period, and the seller must state that period clearly. In a progressively increased reduction within the same campaign, the prior price is the price before the first reduction. **Method** We started from an AI deep-research report written as a product specification for a price-history app. We removed the product, pricing and app-store material and kept the legal claims a merchant needs. On 11 October 2026 we checked them against the text of Directive (EU) 2019/2161, the Commission's guidance notice in the Official Journal, the Court of Justice press release on case C-330/23 and the consolidated Romanian decision on the official Romanian legislation portal. We did not verify the report's descriptions of German, French, Polish, Italian, Spanish and Dutch national law, or its accounts of national fines, so this article does not repeat them. **Where the report was wrong** It understated the services exclusion. The report said the Price Indication Directive is centred on goods rather than services. The Commission guidance is firmer: Article 6a does not apply to services or digital content at EU level. It presented an inference as a ruling. The report attributed to the Court of Justice a ban on placing the prior price in an accordion, a tooltip or a remote footnote. The Aldi Süd ruling is about how the reduction is calculated. The legibility requirement comes from the directive's general rule, and how it applies to a particular design is a matter for national authorities. It overstated where the prior price must appear. The report said official guidance treats ads, search results and product listings as places where the prior price is required. For general announcements, the Commission guidance requires the prior price at the point of sale, not on the banner itself. Some national authorities may go further; we did not verify those claims. It described a national rule in a way the guidance says is not allowed. The report described one country's rule as a full exemption for products sold for the first time. The Commission says the new-products option allows a shorter period, not an exemption. Either the national rule goes beyond the option or the report described it loosely. We did not check the national text. **Limitations** This is a reading of EU law and one national rule, not legal advice. The Commission guidance is not binding; only the directive and the Court's judgments are. The guidance reflects case law up to October 2021, and the Aldi Süd judgment came later. We read the Court's press release, not the full judgment text. We checked one national transposition. Stores selling into several Member States should confirm each country's choices on perishables, new products and progressive reductions. ### Where to get street-level postal-code data for Romania, and how reliable it is The only official bulk file of Romanian postcodes with street ranges is Poșta Română's 2016 workbook on the national open-data portal, reusable commercially under OGL-ROU-1.0. It covers streets only in Bucharest and 47 large localities; everywhere else the code belongs to the whole locality. Newer-looking free sources we checked either lack postcodes, lack streets, or repackage the same 2016 file. **The short answer** The official source of street-level postal codes for Romania is Poșta Română's INFOCOD nomenclature. The newest version published as open data is a 2016 Excel workbook on data.gov.ro, licensed under the Open Government Licence Romania 1.0 (OGL-ROU-1.0), which allows commercial reuse with attribution. It gives street and number-range codes for Bucharest and 47 large localities, and one code per locality for the rest of the country. No free source we checked on 11 October 2026 offers a newer nationwide table linking street, number range and postcode. Street registers (OpenStreetMap, the state register RENNS) tell you which streets exist; they do not tell you their postcodes. Build with two layers, a postal layer and a street layer, and record for every code whether it was assigned to a street segment or to a whole locality. **What is in Poșta Română's open workbook** The dataset ‘Coduri postale Romania’ on data.gov.ro is published by Poșta Română. Its catalogue entry was created on 31 October 2013 and last modified on 6 September 2016. It lists four Excel resources: an October 2013 file, a May 2016 file, and two resources labelled ‘Infocod Sept.2016 cu SIRUTA’ whose file name says infocod-cu-siruta-mai-2016.xls. The label and the file name disagree; the file's own metadata says it was last saved on 6 September 2016. We downloaded that workbook and read it. It has three sheets. The Bucharest sheet has 12,400 rows with street type, street name, number range, postcode, sector and the SIRUTA code (the national statistics code for an administrative unit). The ‘Localitati peste 50.000 loc’ sheet has 29,204 rows covering 47 localities, with the same street and range columns. The ‘Localitati sub 50.000 loc’ sheet has 13,803 rows, one per locality, with county, locality, postcode and SIRUTA and no street columns at all. In total that is 55,407 rows and 37,914 distinct postcodes. The licence is the strongest part. OGL-ROU-1.0, issued by the Romanian Government's General Secretariat in 2014, allows you to copy, publish, distribute and adapt the information and to reuse it for commercial or non-commercial purposes, provided you state the source (the licence supplies the notice text) and do not suggest an official status or a special relationship with the public body. **Street level means Bucharest and 47 large localities** ‘Street-level postal data for Romania’ is narrower than it sounds. Poșta Română assigns postcodes to street segments only in Bucharest and the larger localities; in the other 13,803 localities of the workbook the postcode is attached to the locality as a whole. That is how the postal system is built, not a gap in the file. The practical consequence: when you expand a village's single code over a list of its streets, the result is correct, but it is a locality assignment, not evidence that Poșta Română coded each street. Store an assignment level with every row (segment, street or locality) so that later checks know how much the code actually says. **How reliable the 2016 workbook is** The workbook is authoritative for 2016 and ageing since. Streets built or renamed after that are absent, and nothing in the open-data entry says when, or whether, a newer edition will be published. It also needs cleaning before use. Number ranges are free text such as nr. 1-51C; 4-68, nr. 21-T (T meaning to the end of the street) or a list of blocks, so parity and range limits have to be parsed and the raw string kept. Personal names are stored surname first with an abbreviated title. All Romanian ș and ț characters use the old cedilla form rather than the correct comma-below letters. And 73 rows in the large-localities sheet carry trailing spaces in the locality or street name, which turns Cluj-Napoca into two different localities if you group naively. One operational warning from our check: the TLS certificate of data.gov.ro expired on 9 October 2026, so on 11 October strict HTTPS clients refused to download from the portal. Keep your own archived copy and its checksum. **Poșta Română's live search** Poșta Română runs a public postcode search on its website, starting from a county list and returning postcode, county, locality, street and number, and postal unit. It is the reference for checking a single address today. The page does not mention an API, a bulk download or terms for automated use, so it is a verification tool, not a data feed. A current machine-readable INFOCOD under licence would have to be requested from Poșta Română directly; we found no public offer or price for one. **RENNS, the national street register** RENNS (Registrul electronic național al nomenclaturilor stradale) is the state register of street names and administrative addresses, run by ANCPI, the national cadastre agency, under Government Decision 777/2016. Local authorities are obliged to enter and update their streets, and the decision says the general data in RENNS are accessible to anyone free of charge for searching and viewing administrative addresses. An administrative address in RENNS includes the street type, street name and administrative number. RENNS does not hold postcodes. Two limits matter for builders. The decision describes free consultation, not a public bulk API, so automated reuse needs ANCPI's agreement. And availability is not guaranteed: ANCPI reported a cyberattack that took all its IT systems offline from 14 July 2026, and on 11 October 2026 the RENNS host name did not resolve from our checks. Treat RENNS as a valuable street list when you can get it, not as something to depend on. **Open alternatives and what each is good for** OpenStreetMap. The Geofabrik extract for Romania (314 MB, containing data up to 8 October 2026 when we checked) is the broadest current list of streets and mapped house numbers. Some objects carry a postcode, but OSM is not a postal authority. It is licensed under the ODbL: you must credit OpenStreetMap, state the licence, and release an adapted database under the same licence. GeoNames. The postal-code export for Romania (a 280K file) is under CC BY 4.0. It links codes to places and coordinates, often estimated, and has no street ranges. Eurostat GISCO. The postal-code point dataset is under CC BY-SA 4.0, and Eurostat warns that not all existing codes may be included or correctly located. Use it for geometry, not validation. OpenAddresses. The Romanian source folder contains exactly two sources, for Bucharest and the Bucharest metro area. It is not national. COMPLEXIO Nomenclator Stradal. A web application for municipalities to manage street naming and numbering and export to RENNS. Its site mentions no national database, postcodes, public API or prices. **Third-party postcode sites** Several free sites offer Romanian postcode search and APIs. We looked closely at one, codul-postal.ro, which advertises a ‘complete 2026 database’ of 37,914 postcodes and 13,851 localities, with data ‘from Poșta Română’ and ‘updated periodically’. Those numbers match the 2016 workbook exactly: 37,914 distinct codes, and 13,803 small localities plus 47 large ones plus Bucharest makes 13,851. The most likely reading is that the site serves the 2016 file. A 2026 label on a site is not evidence of 2026 data; compare counts before you trust one. **Where the report was wrong** Our starting report was mostly accurate on the structure of the official file, but we corrected these points. Row count. It sized the official data at about 51.9k records, based on a third-party conversion. The workbook itself has 55,407 data rows. Third-party APIs. It suggested codul-postal.ro as a comparison oracle without noticing that its advertised count equals the 2016 file. A comparison against the same data proves nothing. RENNS. It described public RENNS endpoints as reachable in August 2026. By 11 October the service was not reachable from our checks, after the July incident. GISCO. It recommended the Eurostat dataset without mentioning that it is share-alike. **Method** We started from an AI deep-research report on Romanian street-level postcode sources. On 11 October 2026 we checked each source at its own page: the data.gov.ro catalogue entry and licence, the workbook itself (downloaded and counted by us), Poșta Română's search page, the text of Government Decision 777/2016, ANCPI's incident notice as reported by the national news agency, the Geofabrik, GeoNames, Eurostat, OpenStreetMap and OpenAddresses pages, the COMPLEXIO product page and codul-postal.ro. We could not verify the report's claims about INSPIRE download-service terms for RENNS, about undocumented RENNS endpoints, about the other third-party APIs it named, or about any current INFOCOD offer from Poșta Română, so we left them out. **Limitations** This is a desk check of availability and terms, not a measurement of accuracy against deliveries. We did not test how many 2016 codes are still valid today. Licence summaries here are not legal advice; read the licence of each dataset you combine, especially when mixing attribution-only and share-alike sources in one database. ### The most common mistakes in Romanian delivery addresses, and how to correct them Most broken Romanian addresses fail on five things: a postcode that does not match the locality, a village written without its county, a street type fused into the street name, two incompatible versions of ș and ț, and block details lost in free text. We checked each rule against Poșta Română's own data and the UPU addressing sheet, and found that several popular ‘fixes’ are wrong. **The short answer** A correct Romanian delivery address has a six-digit postcode written to the left of the locality, a locality that is unambiguous within its county, a thoroughfare type before the street name, and building details (block, staircase, floor, apartment) at the end of the street line. The most common mistakes are a postcode that belongs to a different locality or street segment, a village name given without its county, and street names that do not match the official list because of word order, titles or the wrong form of ș and ț. The way to correct a Romanian address is to match it against the official postal nomenclature rather than guess: the postcode determines the county and, in large cities, the street segment, so a wrong postcode cannot be repaired by changing a digit or two. **How a Romanian postcode is built** A Romanian postcode has 6 digits and sits to the left of the locality name, according to the Universal Postal Union's addressing sheet for Romania. The UPU sheet describes the coding method: the first digit is the postal region, the second is a district of that region or, in Bucharest, the sector, and the remaining digits identify the delivery area. Its own examples are 060274 BUCHAREST and 200716 CRAIOVA. In practice the first two digits identify a county. In Poșta Română's 2016 postal workbook, every locality-level code in Cluj county starts with 40, every one in Brașov county with 50, and every one in Ilfov with 07. A postcode with five digits, a space or a hyphen is therefore malformed, and a postcode whose first two digits point to a different county than the one written in the address is a red flag that the customer copied the code of another place. The repair is a lookup, not arithmetic. Inside a large city, consecutive codes belong to different streets or even different number ranges of the same street, so ‘adding the missing digit’ or picking a code that looks close produces a valid-looking code for somebody else's street. **Bucharest: six sectors and the 0 prefix** Bucharest is divided into 6 sectors, a structure set out in the city's own council decisions. In a Bucharest postcode, the first digit is 0 and the second digit is the sector. We checked this against every one of the 12,400 rows in the Bucharest sheet of Poșta Română's 2016 postal workbook: all start with 0, and in all of them the second digit equals the sector column, with 0 mismatches. Two corrections follow. If an address says ‘Sector 3’ and the postcode starts 06, one of them is wrong, and the street decides which. And a code starting 07 is not Bucharest at all: it is Ilfov county, which surrounds the capital, so towns next to Bucharest must be written with their own name and county, not as ‘Bucharest’. The UPU sheet allows the sector to be written on its own line between the street and the locality, as in its example Sector 6 above 061357 BUCHAREST. **Villages and communes: the locality is the address** Romanian law organises the territory into four kinds of unit: county, municipality, town and commune. A commune is a rural unit made up of one or more villages, and the village where the commune's administration sits is the commune seat. A customer in a village often writes the commune name, the village name, or both, and frequently leaves out the county. Leaving out the county is the expensive mistake. In the locality-level sheet of the 2016 postal workbook, 1,364 locality names occur in more than one county; Izvoarele and Fântânele each occur in 16 counties. A village name alone is not an address. Outside the large cities, Poșta Română assigns the postcode to the locality as a whole. The same workbook lists 13,803 smaller localities, each with its own single code and no street ranges. For a village, therefore, the correct check is that the postcode belongs to that village in that county; the street line matters to the courier, but it does not change the code. **Street names: type, order and titles** The UPU sheet says the thoroughfare type goes before the name (Strada, Bulevardul, Calea, Aleea, Șoseaua, Intrarea and so on), and the house number usually goes at the end of the street line. Customers abbreviate the type (Str., Bd., Sos., Al.) or fuse it into the name, which breaks exact matching. Poșta Română's file stores the type and the name in separate columns and in a form that does not look like a street sign. The street everyone writes as Drumul Taberei appears as type Drum and name Taberei. Streets named after people are stored surname first, with the title as an abbreviated suffix: the street signed General Eremia Grigorescu is Grigorescu Eremia, g-ral.. In the Bucharest sheet alone, 427 street names carry a trailing title such as dr., prof., lt. or sold. A matcher that wants to find these streets has to split the type from the name, reorder personal names, and treat the title as optional on both sides. Deleting titles from the customer's text is not enough, because the official record still carries them. **Diacritics: two versions of ș and ț** The correct Romanian letters are ș and ț with a comma below (Unicode U+0219 and U+021B). Older systems used the visually similar s and t with a cedilla (U+015F and U+0163), which Unicode treats as different characters. A customer typing on a phone, a shop database and a postal file can each use a different version, and an exact comparison then fails on a perfectly correct address. The postal data itself uses the old form. We counted 45,201 cedilla s characters and 0 comma-below s characters in the 2016 workbook. The fix is to keep the correct comma-below letters in what you print, and to compare on a key that folds both forms, and the plain letters s and t, into one. **Block, staircase, floor and apartment** Romanian apartment addresses carry building details that a courier needs: block (bl.), staircase (sc.), floor (et., or parter for the ground floor) and apartment (ap.). The UPU sheet puts them at the end of the street line, as in its example Drumul Taberei nr. 35, bl. F5, sc. 2, parter, ap. 23. The common mistake is not where these details are written but that they are lost: a checkout form with one short address field truncates them, or a parser treats bl. F5 as part of the street name. In large cities the block can also decide the postcode, because the postal file assigns some codes to named blocks rather than to number ranges. Keep the details, parse them into their own fields where you can, and never drop them to make a street match. **Where the report was wrong** Our starting report contained several confident rules that the official data contradicts. Drumul Taberei. The report said Drumul Taberei is an area, not a street. Poșta Română lists it as a street, and the UPU uses it as the street line in its own Romanian example. Titles. The report said official records omit titles such as General or Dr., so they can simply be stripped. The postal file keeps them, as a suffix after a reordered name. Postcode repair. The report suggested fixing a short or garbled postcode by inserting a county digit, and gave an example of guessing a nearby Cluj-Napoca code. No source supports a mechanical repair; within a city, neighbouring codes belong to different streets. Roads. The report rewrote national roads into invented forms such as ‘Șoseaua Națională 1’. We found no official source for that convention and dropped it. **Method** We started from an AI deep-research report on Romanian address errors. AI research engines can be wrong, so on 11 October 2026 we checked every rule kept here against primary sources: the UPU addressing sheet for Romania, the text of the law on Romania's administrative organisation, a Bucharest council decision on the sectors, Unicode's own records on the comma-below letters, and Poșta Română's postal workbook as published on the national open-data portal. The counts in this article come from our own reading of that workbook. We could not verify the report's claims about historical locality renamings, its section on Czech, Polish, Bulgarian, Hungarian and Slovak address formats, or any official list of street aliases, so we left them out. **Limitations** The newest postal workbook Poșta Română has published as open data dates from 2016. Streets created or renamed since then are missing from it, and codes may have changed. The counts above describe that file, not today's postal network. For a live answer on a single address, Poșta Română's own postcode search remains the reference. This article covers addresses inside Romania only. ### How does Shopify App Store search rank apps? What is documented and what is folklore Shopify does not publish its App Store ranking formula. It documents a handful of things: term matching against your listing, behaviour after a search, a boost for Built for Shopify apps, and a statement that positive reviews lift an app in search. Most of the rest of what circulates as ranking advice is inference, and some of it breaks Shopify's own listing rules. **The short answer** Shopify App Store search ranks apps on signals that Shopify describes only in general terms and never weights publicly. The documented signals are: how well a search matches the text in your listing, how merchants behave with the results after they search, Built for Shopify status, and positive reviews. Install velocity, retention, update frequency and page speed as direct search factors are not documented anywhere we could find; they are inferences repeated across blogs. We checked this because two AI deep-research reports we were given on Shopify App Store optimisation stated ranking factors with confidence, ordered them by impact and attached percentages. When we went to Shopify's own pages, the ordering, the weights and most of the percentages had no source. This article separates the two piles. **What Shopify documents about ranking** Shopify's clearest public statement about App Store search is a partner blog post from 27 February 2023 announcing search improvements. The post says keyword stuffing will have less of an impact, that search now has a built-in mechanism to help smaller, trending, high-quality apps show up higher, and that ranking will be more dynamic than before, with more day-to-day variation. It names two kinds of input: term matching and data about how merchants interact with results after they search. An older partner post, first published in 2018 and now carrying a notice that the listing guidelines changed in 2022, adds two mechanical details that are still useful: the search engine uses the information in your listing to decide which searches should return your app, and it recognises singular and plural forms automatically. Writing both forms of a word into a listing is therefore wasted space. Shopify's review-management documentation adds one more documented signal: positive reviews make an app appear higher in App Store search results and category pages. It does not say how much weight reviews carry relative to anything else. That is the whole documented model. Shopify gives no ordering of factors, no weights and no formula. Any article that ranks the factors by impact is ranking guesses. **What Shopify documents about the listing fields** The fields that search can match against are documented in Shopify's best-practices page for App Store apps, and the limits there are precise. An app name can be at most 30 characters, and the listing URL slug is generated from it. The app introduction is 100 characters and the app details are 500 characters. Each feature should be up to 80 characters, and a listing can select up to 25 structured features per category and up to 6 integrations. The search terms field takes up to five terms, written as complete words rather than fragments, with one idea per term. Shopify's own example is that email marketing is a good term and email marketing for leads is not. Search terms are among the fields covered by automated translation. Categories and tags are described as affecting discoverability, and the instruction is to choose ones that accurately reflect the app's core function. The App Store requirements repeat this as a rule: tags must accurately reflect the app's primary functions. Two requirements matter for anyone writing listing copy to rank. The app card subtitle must not contain keywords added with the intent of improving search performance, and must not include data or statistics. The listing as a whole must not use statistics or data, or superlatives such as the first, the best or the only, and must avoid excessive marketing keywords. **Reviews: what the rules allow** Shopify's overall app rating is not a simple average. The review documentation says the rating is weighted to prioritise recent, useful and trustworthy reviews, and it changes as reviews are added or removed. Only merchants with the app installed can review it, and they can still review within 45 days of uninstalling. The rules on asking for reviews are strict. Incentives of any kind are forbidden, including discounts, free months or features withheld until a review is left, whether in the app or through outside channels. Requests must use neutral language and must never ask for a positive review. Developers should not ask during onboarding or installation, should not interrupt a workflow to ask, and should ask merchants whether they want emails at all. Inside the admin, Shopify provides an App Bridge Reviews API that opens a review modal, which only appears when Shopify's own conditions are met. So the documented link between reviews and ranking is real, but the only legitimate lever is product quality plus a neutral request at a sensible moment. **Built for Shopify: the one documented boost** Built for Shopify is a status Shopify awards to apps that meet its quality bar. Shopify's documentation states that Built for Shopify apps appear higher in App Store search rankings, carry a badge on their app card, can be shown alone through a search filter, and can target their App Store ads to specific merchant plans. Being recommended in the first homepage collection is listed too, with a note that this benefit is not exclusive to the status. The entry requirements are published. An app needs at least 50 net installs from active shops on paid plans, at least five reviews and a minimum recent rating that Shopify does not express as a number. Admin performance is measured at the 75th percentile of page loads, with at least 100 calls over 28 days: LCP of 2.5 seconds or less, CLS of 0.1 or less and INP of 200 milliseconds or less. An app must not reduce a storefront's Lighthouse performance score by more than ten points, and must be embedded with the latest App Bridge. Shopify's partner blog reports that apps reaching the status see an average increase of 49% in new installs within 14 days. That is Shopify's own average, with no published method, and it cannot separate the effect of the badge from the fact that apps good enough to qualify were already growing. **App Store ads are a separate auction** Paid placements are not organic ranking. Shopify App Store ads are charged per click in an auction that weighs both the bid and the app's relevance to the search and the merchant. Search results show 4 ad placements on desktop and 3 on mobile, and every ad carries a badge that marks it as not an organic listing. Shopify's advertising pages say nothing about ads affecting organic rank, so any claim that buying ads lifts organic position is undocumented. **What is folklore: claims we could not source** The following claims were stated as fact in the reports and are repeated widely in App Store optimisation blogs. We found no Shopify document that supports them. They may be true; they are not documented. Install velocity and retention as direct ranking factors. Shopify mentions behaviour after search and a boost for trending smaller apps, which is close, but never names installs, uninstalls or retention. Recent review activity as a search signal. Shopify documents recency weighting in the displayed rating. It does not say that review recency or review velocity feeds search separately. Release cadence. The advice that frequent small updates give a ranking boost has no source. Page speed as a general ranking factor. Web Vitals thresholds are Built for Shopify entry requirements. Shopify does not say that speed ranks apps outside that status. Conversion benchmarks. Figures such as typical page-view-to-install rates, the lift from a free-plan label, the lift from a demo store link, or the share of App Store browsing done on mobile were given without sources, and we found none. **Where the reports were wrong** 1. Advice that breaks the listing rules. One report recommended outcome-driven copy in the introduction, with a percentage increase or a money saving. Shopify's requirements forbid statistics or data in the listing, introduction and subtitle. Following that advice risks rejection at review. 2. Tagging everything. One report suggested enabling every relevant feature tag to catch long-tail searches. The requirement is that tags reflect the app's primary functions accurately, which points the other way. 3. Review requests by email to everyone. One report advised emailing every existing user to ask for a review and asking during onboarding calls. Shopify's guidance says not to ask during onboarding and to ask merchants whether they want emails before sending them. 4. Sidekick as a Built for Shopify benefit. One report listed recommendations in Sidekick, Shopify's admin assistant, among the badge's benefits. Shopify's page lists Sidekick recommendations under a separate visibility achievement, not under Built for Shopify. 5. A subtitle length we could not confirm. One report gave the app card subtitle a fixed character limit. Shopify's best-practices page, as we read it, gives limits for the name, introduction, details and features but not for the subtitle, and third-party guides disagree with each other. We left the subtitle limit out. **Method** We started from two AI deep-research reports on Shopify App Store optimisation. Both were written by AI research engines and both mixed Shopify documentation with blog material and invented-looking benchmarks. We picked the claims a merchant or app builder would act on and checked each at a primary source on 11 October 2026: Shopify's developer documentation for listing best practices, App Store requirements, review management, advertising and Built for Shopify, plus Shopify's own partner blog posts. Claims we kept are listed with their sources. Claims we could not trace to Shopify are labelled undocumented above or dropped. We could not verify: the app card subtitle limit, any numeric weight for any factor, the view-to-install and free-label benchmarks, churn and activation percentages, and the claim that Shopify search treats review recency separately from the displayed rating. **Limitations** Shopify changes its documentation without versioning it publicly, and some of the pages we read had moved since the reports were written. The limits and rules above are what the pages said on the day we checked. Undocumented does not mean false: Shopify may use signals it chooses not to publish, and observational studies of rank can still be informative. They are just not evidence of the mechanism. Finally, the only effect size here, the install lift after Built for Shopify, is an average Shopify reports about itself. ### Shopify B2B in 2026: what the native features cover and when a wholesale app is still needed Since 2 April 2026, Shopify's own B2B tools are on the Basic, Grow and Advanced plans, not only Plus: company profiles, up to three market catalogs, volume pricing, quantity rules and net payment terms. Plus keeps unlimited catalogs, catalogs assigned to individual companies, deposits and partial payments. Wholesale apps still matter mainly for per-customer pricing below Plus, registration flows and themes or apps that native B2B does not support. **The short answer** Shopify B2B is native on every main plan in 2026. On 2 April 2026 Shopify opened company profiles, up to three active catalogs, volume pricing, quantity rules, net payment terms, vaulted cards and US ACH bank payments to the Basic, Grow and Advanced plans. Unlimited catalogs, catalogs assigned directly to a company or company location, deposits, partial payments and payment requests per fulfilment remain on Shopify Plus. A wholesale app is therefore no longer needed just to show business customers different prices. A wholesale app still earns its place when a store below Plus needs prices that differ per customer rather than per market, wants a wholesale registration and approval form, needs features native B2B does not support, or wants to keep legacy customer accounts. In this article, a catalog is Shopify's object that decides which products a B2B buyer sees and at what price. A company is a business customer with one or more company locations, each of which can carry its own payment terms and catalogs. **What native Shopify B2B includes on every plan** Shopify's B2B plan comparison lists the following as available on Basic, Grow, Advanced and Plus: companies and company locations, location-level permissions, quantity rules, quantity price breaks (volume pricing), net terms, payment reminders, ACH bank payments for US merchants, vaulted credit cards, draft order to invoice, checkout to draft, easy reorders, PO numbers, sales staff permissions, the Trade theme with a quick order list, and Shopify Flow with B2B objects. Catalogs below Plus. On Basic, Grow and Advanced, a store can assign up to 3 active catalogs across all its B2B markets, and the store must use the new Shopify Markets. Shopify states that direct catalog assignment is not available on those plans. In practice, a non-Plus store can price by market or customer segment, not by individual company. Payment terms. Shopify's payment-terms page lists no payment terms, net terms of 7, 15, 30, 45, 60 or 90 days counted from the order date, due on fulfilment, and a fixed date that can be set only on individual draft orders. The plan comparison marks net terms as available on every plan. Collecting what is due. Shopify's payment-terms page notes that payments are not captured automatically when terms expire; staff charge a vaulted card from the admin. A Shopify changelog entry of 17 June 2026 adds a Shopify Flow action, Charge vaulted payment for B2B order, that charges a vaulted card or debits a vaulted bank account when payment on an order with terms is due. The changelog does not state a plan requirement. Themes. A Shopify changelog entry of 10 December 2025 says Horizon themes are fully compatible with B2B, including volume pricing, quantity rules and quick order lists, without custom development or third-party apps. **What stays on Shopify Plus** Shopify's April 2026 announcement and the plan comparison agree on the Plus-only list: unlimited catalogs for customer-specific pricing, direct assignment of catalogs to companies and locations, deposit requirements, partial payments and payment requests per fulfilment. Contextual checkout and storefront through Shopify Markets are marked as Plus and Advanced, not Basic or Grow. The practical line is per-company pricing. A store whose wholesale customers each have their own negotiated price list needs Plus to do that natively, because only Plus can assign a catalog to a single company. A store with a few price tiers can usually fit them into three market catalogs. **Native B2B limits that apply on every plan** Shopify's B2B considerations page lists constraints a merchant should check before switching on B2B. Legacy customer accounts cannot be used for B2B orders and customers; B2B needs the newer customer accounts. Some third-party apps do not work with B2B, and checkout changes made in checkout.liquid are incompatible. The same page says accelerated checkouts, local delivery, pickup points, tipping and subscriptions are incompatible with B2B, that B2B orders are limited to 500 line items, and that B2B rates apply only when the order has a B2B customer assigned to a company location. In a blended store (retail and wholesale on one storefront), most other features cannot be limited to one customer type; discounts can be limited to B2B by creating a B2B market and using it in the discount's eligibility. Shopify does not offer, natively, a public wholesale application form with approval, price lists that vary per customer below Plus, or customer-specific credit limits. Those are the gaps wholesale apps sell into. **What wholesale apps add** We read three wholesale app listings on the Shopify App Store on 11 October 2026. BSS B2B Wholesale Pricing shows a rating of 4.9 from 1,187 reviews and the Built for Shopify badge. BSS lists custom price lists, customer-specific and tiered pricing, B2B registration with approval and auto-tagging, minimum order values and pack sizes, tax-exempt customers, net payment terms, POS support and, on its top plan, APIs for ERP price sync. It has a free plan and paid tiers with a 14-day trial. SparkLayer B2B & Wholesale shows 4.9 from 388 reviews. SparkLayer's free plan allows up to 3 price lists; paid plans add unlimited price lists, a sales rep portal, and on the highest plan quoting, API access and accounting connections. SparkLayer caps B2B orders per month on each plan, which is a different pricing model from the others. Wholesale Gorilla shows 4.8 from 317 reviews, has no free plan and a 21-day trial. Wholesale Gorilla lists price rules by store, collection, product or variant, customer price groups, quick order forms, wholesale shipping rules, net terms, order limits and product locking. The overlap with native B2B is now large: volume pricing, quantity rules, net terms and quick order are all native. What the apps still offer that native B2B below Plus does not is per-customer pricing beyond three market catalogs, registration and approval flows, and pricing that works without moving to the newer customer accounts. A merchant evaluating an app in 2026 should first list which needs native B2B already covers on the current plan. **Where the reports were wrong** 'Price lists are only available on Plus.' Out of date. Since 2 April 2026, Basic, Grow and Advanced get up to 3 active market catalogs with their own prices. Only direct catalog assignment to a company and unlimited catalogs are Plus-only. 'Shopify Plus only has fixed Net 15, 30 or 60 terms.' Wrong twice. Shopify lists net 7, 15, 30, 45, 60 and 90, plus due on fulfilment and fixed-date terms on draft orders, and net terms are on every plan, not only Plus. 'All the leading wholesale apps carry the Built for Shopify badge.' Not on 11 October 2026: the SparkLayer and Wholesale Gorilla listings did not show the badge; BSS did. Stale counts. One report gave BSS 1,067 reviews, SparkLayer 347 and Wholesale Gorilla 307; the listings showed 1,187, 388 and 317. A review count in any comparison is a snapshot. What held up. The second report's account of the 2 April 2026 change was accurate: three catalogs, company profiles, volume pricing and payment terms on non-Plus plans, with unlimited catalogs, direct assignment, deposits and partial payments kept on Plus. Its June 2026 date for automated vaulted-card charges matches the Flow action in the changelog. **Method** We started from two AI deep-research reports on Shopify B2B and wholesale pricing apps. We kept only claims we could check on 11 October 2026 at the Shopify Help Center (B2B plan features, catalogs, payment terms, considerations), the Shopify changelog, Shopify's own announcement, and the apps' App Store listings. App figures are quoted from those listings as public facts about third parties. We dropped what we could not verify: individual merchant review quotes about price errors, the reports' descriptions of how each app injects prices into themes, claims about apps we did not open (Sami, Wholesale Pricing Discount, B2B Wholesale Hub, Discounty, Addify, Bold), the reported B2B Apple Pay support, which conflicts with Shopify's statement that accelerated checkouts are incompatible with B2B, a claim that B2B discounts are now on by default, and all market-size estimates, which rested on assumed adoption rather than data. **Limitations** Shopify updates its help pages without version numbers; this article describes them as they read on 11 October 2026. We did not set up B2B in a live store, so behaviour in edge cases, such as volume pricing across several catalogs, is as documented, not as tested. We read three app listings, not the whole wholesale category, and a listing is the vendor's own claim. App ratings, review counts and plan contents change often. ### Which Shopify platform changes and deadlines open or close app opportunities in 2026? In 2025–2026 Shopify moved its AI-agent endpoints to the Universal Commerce Protocol, gave B2B and customer accounts new surfaces, and set hard end dates for Shopify Scripts, script tags and older catalog and cart APIs. We checked every date against Shopify's developer changelog and Help Center, and list what the AI research report we started from got wrong. **The short answer** The Shopify changes that open room for new apps in 2026 are the move of storefront AI-agent endpoints to the Universal Commerce Protocol (UCP, an open specification for how AI agents query a store's catalog and cart), store credit for B2B company locations, deposits on draft orders, and a single branding API for checkout and customer accounts. The changes that close doors are dated: Shopify Scripts stopped running on 30 June 2026, script tags can no longer be created from 1 October 2026 and stop running on 1 March 2027, the old Storefront MCP cart tools were kept only until 31 August 2026, and the Global Catalog REST API stops on 2 November 2026. Several of the new surfaces are limited to Shopify Plus or are read-only on the customer side, which narrows who can use an app built on them. We checked each date on 11 October 2026 at Shopify's developer changelog, its documentation or its Help Center. **AI agents: from Storefront MCP to UCP** Catalog. Shopify's changelog lists 22 April 2026 as the date the Storefront Catalog MCP server began implementing the UCP Catalog capability. The current documentation confirms that the Storefront Catalog MCP implements the UCP Catalog capability, exposes the tools search_catalog, lookup_catalog and get_product on the store's /api/ucp/mcp endpoint, and requires an agent profile in every request. Cart. On 24 June 2026 Shopify deprecated the older Storefront MCP cart tools, get_cart and update_cart, and said it would maintain them until 31 August 2026. The replacements are the UCP Cart MCP tools on the same UCP endpoint, which create, read, update and cancel a cart. On 4 September 2026 Shopify announced that storefronts support UCP version 2026-08-25. Global Catalog. On 9 October 2026 Shopify announced that the Global Catalog REST API, deprecated in April 2026, stops serving traffic on 2 November 2026. The replacement is the Global Catalog MCP, which also implements UCP. Outside Shopify. OpenAI launched Instant Checkout in ChatGPT, together with the Agentic Commerce Protocol, on 29 September 2025. In March 2026 OpenAI changed course: it said the first version of Instant Checkout did not offer the flexibility it wanted and that merchants could use their own checkout while it focused on product discovery. For an app builder, the lesson from both platforms is the same: agent-facing endpoints changed several times within a year, so a product that only wraps one endpoint has a short life. **B2B: store credit, payment-method intents and deposits** Store credit per company location. On 15 September 2025, with API version 2025-10, Shopify let B2B company locations own store credit accounts. Merchants can issue credits, refund to credit and manage balances per location in multiple currencies; B2B buyers signed in to the new customer accounts can see and spend that credit at checkout for the locations they may order for. Payment-method updates. On 26 January 2026 Shopify added an Intents API to customer account UI extensions that lets a buyer replace the payment method on a subscription contract without leaving the extension. Shopify's native flow handles the card details, so the app never stores them. Deposits on draft orders. On 1 July 2026, with API version 2026-07, Shopify added deposit fields to draft orders. Apps can set a deposit through the Admin API, splitting what is due at checkout from what is due later, for example on fulfilment. The Customer Account API exposes the deposit read-only. The feature is available only to Shopify Plus stores. Together these let an app build a B2B quote, deposit and credit flow on native objects instead of a parallel invoicing system. The limit is the audience: a deposit-based workflow can be sold only to Plus merchants. **Customer accounts become an app surface** Announcement bar. On 28 August 2025 Shopify added an announcement bar extension for the Thank you page and for customer account pages such as order status, order index and profile, starting with UI extension API version 2025-07. Market-aware sign-in. On 24 February 2026 Shopify added optional locale and country parameters to the Customer Account API sign-in URL, so headless stores can show the login screen in the right language and market context. One branding API. On 13 May 2026, with API version 2026-04, Shopify introduced the Checkout and Accounts Configuration API for shared branding across checkout, customer accounts and sign-in, with per-market settings. It replaces the Checkout Branding API and the Checkout Profile API, which are now deprecated, and is available only to Shopify Plus merchants. Metafield definitions. Since 16 June 2026, app-owned metafields without a definition no longer return a value through the Customer Account API. Apps must create a definition, with Customer Account API access, on each store that installs them. Metafields owned by customers or orders are not affected. On 8 October 2026 Shopify added that every metafield declaration in a checkout or customer account extension needs an owner_type from API version 2027-01, or deployment is blocked. **Doors that closed or are closing** Shopify Scripts. Shopify's changelog set the end of editing and publishing for 15 April 2026 and the end of execution for 30 June 2026. The Help Center now says that any Scripts still published on a store have been deactivated and no longer work, and points merchants to Shopify Functions, either through apps or a custom build. Script tags. On 24 August 2026 Shopify announced that from 1 October 2026 the scriptTagCreate and scriptTagUpdate mutations return an error, and that on 1 March 2027 Shopify stops injecting script tags into storefronts. The change covers every API version, so pinning an older version does not delay it. The replacements are app embed blocks in theme app extensions, or web pixels for analytics and conversion scripts. Every app that still relies on a script tag needs a migration before 1 March 2027. Deadlines like these create short-lived demand for migration help and remove apps that are not maintained, which changes the competition in a category without anyone launching anything. **How to read these changes as an app builder** Three questions sort the list. First, who can use the surface: deposits and the branding API are limited to Shopify Plus, which is a small slice of stores. Second, is the buyer-facing side read-only: the deposit is visible to buyers but only an app with Admin access can set it. Third, has the surface already changed once: the AI-agent endpoints changed in April, June, August and October 2026, so an app built on them needs a budget for keeping up. Deprecations are as useful as launches. A dated end of life, such as the one for script tags, tells you which existing apps must change and when merchants will notice. **Method** We started from an AI deep-research report on Shopify app opportunities created by platform changes and compliance deadlines. We treated every claim as unverified. On 11 October 2026 we checked each Shopify date on the developer changelog entry, the developer documentation or the Help Center, and the OpenAI dates on OpenAI's own announcement pages and their published quotations. We also read the most recent changelog entries and added the closing deadlines the report did not mention: script tags, the Global Catalog REST API and Shopify Scripts. We dropped all App Store review counts and the report's opportunity scores: review counts change daily and the scores were the report author's own judgement. We could not find at a primary source the report's claims about web pixels on customer account pages or a December 2025 tightening of protected customer data scopes, so they are not included. The regulatory deadlines in the report are covered, with corrections, in our separate EU checklist. **Where the report was wrong** It missed the biggest closing deadlines. The report described only new surfaces. It did not mention that Shopify Scripts stopped on 30 June 2026 or that script tags stop on 1 March 2027, both of which force existing apps and stores to change. It omitted the plan limits. Draft-order deposits and the Checkout and Accounts Configuration API are available only to Shopify Plus, and the Customer Account API side of deposits is read-only. The report presented them as general building blocks. It overstated the payment-method feature. The report described native payment-method updates inside customer accounts in general. Shopify's entry is specific to replacing the payment method on a subscription contract. It treated OpenAI's checkout as an expanding channel. The report cited an expansion of product discovery in April 2026 as momentum for agent checkout. OpenAI's own change in March 2026 moved merchants back to their own checkout. It also dated store credit for company locations only as late 2025; the entry is dated 15 September 2025. **Limitations** The changelog is Shopify's own record and shows when a feature was announced, not when every store received it. Availability by plan, region or API version can change after we checked. We did not test any API ourselves, and we did not measure demand for apps on any of these surfaces. All dates were checked on 11 October 2026. ### Returns and exchanges on Shopify in 2026: what the platform does natively and where apps fill gaps Shopify's native returns now cover more than most comparison articles admit: return rules, self-serve return requests, refunds to store credit and a report of returns by reason. The real gaps are narrower: customers cannot request an exchange themselves, and return labels inside Shopify exist only for US domestic orders. **The short answer** In October 2026, Shopify natively lets a merchant set return rules (window, restocking fee, return shipping fee, final-sale items), lets customers submit return requests from their customer account, lets staff refund to store credit, and reports returned products by reason. Shopify does not let customers request an exchange through self-serve returns, and Shopify only creates return labels for US domestic orders. A returns app on Shopify is therefore no longer needed just to have a return process. A returns app earns its place when a store wants customer-initiated exchanges, automatic approval, return labels outside the US, or incentives that steer a refund towards an exchange or credit. In this article, native means features in the Shopify admin and customer accounts with no app installed. Self-serve returns means the customer starts the request from their account instead of writing to support. **What Shopify does natively** Return rules. Shopify's return rules offer a return window preset of 14, 30 or 90 days, unlimited, or a custom number of days. The window starts on the delivery date, or on the fulfilment date plus a transit buffer when no delivery date is known. A merchant can add an optional restocking fee as a percentage of the return, and choose between free return shipping, a flat return fee charged once per return, or customers buying their own label. Final-sale exceptions can be set by product or by collection, but bundles cannot be marked final sale. Rules apply to manual returns too. Shopify states that return rules apply to all orders, including B2B orders, and that rules such as restocking fees apply even when staff process a return manually in the admin without self-serve returns switched on. Self-serve return requests. With self-serve returns switched on, customers submit a return or cancellation request from their account and can give a reason and the condition of the item. The merchant reviews every request in the admin, approves or declines it, and on approval can attach shipping instructions, a return label and exchange items. Store credit. Since 18 July 2025, staff can refund a return to store credit, to the original payment method, or to a mix of both, and can optionally set an expiry date on the credit. The staff member needs to be the store owner or hold the refund-to-store-credit permission. Reasons in reports. Shopify has offered a fixed list of return reasons for staff since 31 October 2023. Shopify's refund documentation names a report, Top returned products with reasons, that groups returned items by reason, and notes that it uses only reasons recorded on returns, not notes typed into a plain refund. **Where the native flow stops** No customer-requested exchanges. Shopify's self-serve returns page says exchanges cannot be requested in self-serve returns, and exchange-specific return rules are not available. When a customer asks for an exchange inside a return request, the documented workaround is to decline the request and create a new return with exchange items in the admin. The exchange itself is native; letting the customer choose the replacement is not. Labels outside the US. Shopify's return-label documentation says return labels are available only for US domestic orders, with USPS, UPS and FedEx, billed when the carrier scans the parcel. A store shipping from anywhere else, or returning from a US territory, military address or another country, has to buy the label from a carrier, a shipping app or a returns app. Manual approval. The merchant decides every request. Shopify documents no rule that approves low-risk returns automatically, so volume means staff time. Legacy customer accounts. Self-serve returns do not work with legacy customer accounts. A store that wants to keep the older accounts can add a link to the newer customer accounts so customers can still reach the request form. **What returns apps add** We read the public Shopify App Store listings of four leading returns apps on 11 October 2026. All four advertise a branded return portal, exchanges started by the customer, store credit inside the return flow, rule-based automation and return labels beyond the US. Beyond that, the four apps differ mainly in how they charge. AfterShip Returns & Exchanges shows a rating of 4.7 from 1,542 reviews and the Built for Shopify badge. The AfterShip listing has a free plan and paid plans with a monthly return allowance and a fee per extra return; it advertises exchange offers, fraud checks, return rules and analytics. Return Prime shows 4.8 from 773 reviews. Return Prime's free plan includes 5 return requests, every plan charges per extra request, and the free plan lists an EU-compliant withdrawal button. Loop Returns & Exchanges shows 4.6 from 446 reviews. Loop's free plan includes US and Canada domestic return labels; instant exchanges, Shop Now, bonus credit and fraud prevention sit on its highest listed plan. ReturnGO shows 4.9 from 406 reviews, has no free plan and a 14-day trial, and each plan includes a set number of returns with a fee per extra return. ReturnGO lists auto-exchange, auto-refund and auto-approval rules and photo or video uploads. The common pricing model is a monthly allowance of returns plus a charge per return over it. For a merchant, that means the app's cost scales with the return rate, which is worth modelling before choosing a plan. **Two rules that change the comparison in 2026** The EU withdrawal function. Directive (EU) 2023/2673 adds Article 11a to the Consumer Rights Directive 2011/83/EU. Traders selling at distance to consumers through an online interface must offer a withdrawal function labelled with the words 'withdraw from contract here' or an equally unambiguous phrase, continuously available through the withdrawal period. Member States apply the national measures from 19 June 2026. A Shopify return request form is not automatically that function; a store selling to EU consumers should check its national transposition and whether its returns app claims to cover it. Customer Account API for Built for Shopify returns apps. A Shopify developer changelog post of 17 June 2026 says that, effective 1 December 2026, returns and exchanges apps offering buyer-facing self-service must authenticate customers through the Customer Account API, or risk losing Built for Shopify status. Merchants choosing an app now can ask the vendor whether its portal already meets that rule. **Where the reports were wrong** 'Shopify does not generate return labels.' Wrong for US domestic orders, where Shopify sells USPS, UPS and FedEx return labels. Correct for every other origin. 'Exchanges and store credit flows are absent.' Half wrong. Refunds to store credit have been native since 18 July 2025, and staff can add exchange items to a return in the admin. Only customer-requested exchanges are missing. 'There is no consolidated reporting; return reasons live inside orders.' Wrong. Shopify documents a report of top returned products with reasons. We could not confirm on which plans that report appears. Stale listing figures. The report gave AfterShip 1,267 reviews and Loop 4.7 from 409 reviews; on 11 October 2026 the listings showed 1,542 for AfterShip and 4.6 from 446 for Loop. Review counts move every week, so any figure in a comparison article is a snapshot. Missing rules. The report did not mention the December 2026 Customer Account API requirement or the June 2026 EU withdrawal function, the two changes most likely to affect which app a merchant picks this year. **Method** We started from an AI deep-research report on the Shopify returns app landscape. We kept only claims we could check at a primary source on 11 October 2026: the Shopify Help Center, the Shopify changelog and developer changelog, the official text of the EU directive, and the apps' own App Store listings. App figures are quoted from those listings as public facts about third parties. We could not verify, and therefore dropped, the report's claims about other apps (Redo, ParcelWILL, Rich Returns, ReturnZap, ZigZag, Yanet, Narvar), its claims about enterprise certifications and carrier counts, and its claim that no app serves cash-on-delivery stores in Eastern Europe. **Limitations** Shopify changes its help pages without version numbers; everything here describes the pages as they read on 11 October 2026. We did not test the features in a live store, and we did not check plan availability for each native feature. App ratings, review counts and plan contents change often. We read four listings, not the whole returns category, and a listing is the vendor's own description, not proof that a feature works. Nothing here is legal advice on the EU withdrawal function. ### Should an AI-made YouTube channel for kids make songs or cartoons? Made for Kids rules, COPPA and Google's under-18 clause Songs are the safer bet: made-for-kids videos lose personalised ads, end screens and notifications, and songs can also earn on audio streaming. Google's generative AI terms bar use in services aimed at or likely used by under-18s, YouTube penalises templated and hard-to-follow videos, and the audience label is the creator's legal responsibility, as Disney's $10 million penalty showed. **The short answer** If a small studio wants to run a YouTube channel for young children with AI tools, songs are the safer bet than cartoons. The reason is money, not taste. A video marked made for kids loses personalised ads, comments, end screens, cards and notifications, so YouTube ad revenue per view is low and growth from one video to the next is harder. Songs can also earn outside YouTube, on audio streaming services. Cartoons depend almost entirely on YouTube ads. Three other constraints shape the project. Google's terms for its generative AI services bar their use in a service directed at, or likely to be accessed by, people under 18, which matters if you plan to use Lyria, Veo or Google's image models. YouTube's monetisation rules and its quality principles for kids' content penalise templated, mass-produced and hard-to-follow videos, which is what cheap AI animation tends to produce. And the audience label is the creator's legal responsibility: in 2025 Disney agreed to a $10 million civil penalty over mislabelled videos. We checked each platform and legal fact against the primary source on 19 October 2026. Revenue-per-view figures could only be traced to blogs and forum posts, so we give them as rough ranges with the source named. **What the made-for-kids label switches off** YouTube's help page on features affected by COPPA lists what is unavailable on the watch page of a video marked made for kids: autoplay on home, cards or end screens, video watermarks, channel memberships, comments, the donate button, live chat and live chat donations, merchandise and ticketing, notifications, personalised advertising, playback in the miniplayer, Super Chat and Super Stickers, and saving to a playlist or to Watch Later. On the channel page, memberships, notifications, posts and comments are off. Two items on that list do most of the economic damage. Without personalised advertising, ads are contextual only, which advertisers pay less for. Without end screens, notifications and saving to a playlist, a channel has few tools to move a viewer from one video to the next. Each video has to be found on its own, through search, recommendations or a parent. We found no YouTube figure for how much less made-for-kids videos earn. The numbers in circulation come from blogs and forums. Gyre, a company that sells 24/7 live streaming of pre-recorded videos, puts most made-for-kids channels at $1 to $3 per 1,000 views against $5 to $15 for general content. A creator-earnings calculator site, Learning Revolution, gives $0.50 to $2 for made-for-kids content. One of the reports we started from cited a Reddit thread for $0.15 to $0.30 in Romania. Treat these as orders of magnitude, not a budget. In the European Union, the Digital Services Act points the same way. Article 28(2) says online platforms shall not present ads based on profiling using a minor's personal data when they are aware with reasonable certainty that the user is a minor. The obligation sits with the platform, not with the channel. **Labelling is the creator's legal responsibility** YouTube asks every creator, regardless of location, to say whether each video is made for kids, and its help page states that it is the creator's legal responsibility to comply with COPPA and other applicable laws. The same page tells creators not to rely on YouTube's systems to set the audience for them. The Disney case shows what goes wrong. On 2 September 2025 the FTC announced that Disney would pay a $10 million civil penalty to settle allegations that it violated the COPPA Rule. According to the FTC, Disney set the audience at channel level, so child-directed videos uploaded to channels marked not made for kids stayed unlabelled, and YouTube collected data from children under 13 for targeted advertising. The FTC says YouTube told Disney in mid-2020 that it had changed the designation of more than 300 Disney videos, and Disney did not change its practice. The Department of Justice announced on 30 December 2025 that the court had entered the order. Under the order, Disney must run an audience designation programme that reviews each video it publishes to YouTube. The statement by the FTC chairman and two commissioners gives its length as 10 years, and the FTC says the programme can end early if YouTube adopts age assurance technology that meets COPPA standards. Violations of an FTC trade regulation rule such as the COPPA Rule carry a maximum civil penalty that the FTC raised to $53,088 per violation in January 2025. For a small channel the practical lesson is simple: set the audience on every video, not only on the channel. **Google's under-18 clause: Lyria, Veo and image models vs Suno** Section 20(d) of the Google Cloud Service Specific Terms, headed Age Restrictions, says the customer will not use a Generative AI Service as part of a website, customer application or other online service that is directed towards or is likely to be accessed by individuals under the age of 18. The Gemini API Additional Terms contain the same restriction and add that users must be 18 or older to use the APIs. Lyria, Veo and Google's image models are offered through these services, so the clause applies to all of them. Whether a YouTube channel that publishes finished files made with these tools counts as such a service is a matter of interpretation. One reading, put forward in a report we started from, is that the clause targets products where a child interacts with the AI service, while a studio that generates audio offline and uploads a finished video does not put the service in front of a child. Google's terms do not say this. We did not find an exception, an allow-list or a parental-consent route in the terms we read. This is a contractual risk with Google, not a statement about what is legal, and it is not legal advice. Suno's terms of service, read the same day, contain no comparable clause about services for minors. They set an age floor for the user instead: 18, or 13 with a parent's consent. On the Pro and Premier plans Suno assigns its rights in outputs to the subscriber; on the free plan outputs are for personal, non-commercial use. The terms state that rights in permitted downloads are perpetual and are not affected by cancellation, downgrade or expiry of the subscription. Both providers say the same thing about uniqueness. Google's Cloud terms say a generative service may produce the same or similar output for multiple customers. Suno's terms say output may not be unique and the service may generate the same or similar output for a third party. That matters for Content ID, below. **Inauthentic and hard-to-follow content** YouTube's channel monetisation policies renamed the repetitious content policy to inauthentic content on 15 July 2025. The text now describes generic or repetitive content that looks like it is made with a template. Among the examples of what is not allowed: characters repeatedly placed in the same situation with the same outcome, image slideshows and scrolling text with little narrative or educational value, and AI-generated content made with generic or unoriginal templates giving the impression of mass production. Series with recurring characters are allowed if each video has a distinct storyline, focus or concept. Reviewers look at the channel as a whole, including its main theme and its most-viewed and newest videos. Kids' content faces a second filter. YouTube's best practices for kids and family content list low-quality types to avoid, including hard to follow: videos that are confusing or make no sense, with jumbled storylines, unclear audio or no clear beginning, middle and end. The page adds that this type of video is often the result of mass production or autogeneration. Heavily promotional content and deceptively educational content are also on the list. These principles carry consequences. YouTube says they guide inclusion in YouTube Kids and monetisation, that a channel with a strong focus on low-quality content may be suspended from the YouTube Partner Program, and that an individual video may get limited or no ads. For an AI pipeline, the conclusion is that the script, the storyline and the variety between episodes have to be human decisions. On disclosure, YouTube requires creators to label realistic altered or synthetic content and lists AI-generated music that is the main focus of a video among the cases. Non-realistic animation does not require disclosure. YouTube says disclosure alone will not limit a video's audience or its eligibility to earn money. Pressure from outside is growing too: in April 2026 the advocacy group Fairplay and a coalition of child-development, consumer and online-safety advocates wrote to Google and YouTube asking, among other things, that AI-generated made-for-kids videos be barred from the main YouTube platform. **Autoplay and long compilations** Long compilations of 30 minutes to several hours are a standard format on the large Romanian children's song channels, and they make sense for a parent who wants uninterrupted playback. The reports we started from justified them with a stronger claim: that YouTube turns autoplay off by default for everyone under 18, so a child cannot move on to the next video. The primary sources are narrower. YouTube's autoplay help page says autoplay is off by default for users aged 13 to 17, who can turn it back on. For supervised accounts, a parent or guardian may turn autoplay off, and the YouTube Kids parental controls describe a Disable autoplay switch that the child cannot override. The pages we read do not say that autoplay is off by default for young children. The made-for-kids feature list removes autoplay on the home feed, not autoplay from one video to the next. Compilations are still a sound choice: they suit how parents use the content, and they work whether or not autoplay is on. They are not, on the evidence we found, forced by a platform-wide autoplay ban. **The Romanian market and multilingual dubbing** Romanian children's music on YouTube is dominated by established channels. On the channel pages on 19 October 2026, LooLoo Kids showed 62.6 million subscribers and 42,863,418,773 views, joined in August 2014; TraLaLa showed 7.85 million subscribers and 10,801,789,342 views, joined in October 2012; and HeyKids - Cântece Pentru Copii showed 5.67 million subscribers and 5,655,805,891 views, joined in May 2014. LooLoo Kids and TraLaLa belong to Mora TV, a studio from Iași. According to interviews in the Romanian business press, the company grew by localising songs rather than relying on the Romanian market: an earlier interview cited 18 foreign languages, and a November 2024 article said LooLoo Kids was adapted into 25 languages across a network of about 80 YouTube channels. These are company statements relayed by the press, not figures we could check independently. For a new channel, the lesson is to plan for dubbing from the start: write songs whose structure survives translation, keep vocals and music on separate tracks, and keep on-screen text out of the picture. AI voice and translation tools lower the cost of each extra language. Targeting Romanian alone means competing with incumbents for a small, low-revenue audience. The reports also proposed content gaps: songs about emotions and tantrums, home and online safety, daily routines, and jobs, rather than another version of classic nursery songs or colours and shapes. That is a hypothesis drawn from looking at existing channels. We did not measure search demand for these topics, and it should be tested before production. **Lip-sync without a GPU** The reports recommended a CPU-only lip-sync pipeline: the Gentle forced aligner in Docker, which returns word and phone timings as JSON, then a script that swaps mouth shapes at those timings and assembles frames with FFmpeg. The approach is sound in principle, because forced alignment works from the audio and its transcript rather than from video analysis. Gentle's README confirms the Docker image lowerquality/gentle, a server on port 8765 and JSON output; the project is MIT-licensed. The problem is language. Gentle ships English models. Its repository has an open issue from 2017 asking about other languages, and in an earlier thread the author wrote that there was not yet a system for swapping language models. The Docker Hub image was last updated in 2017. For Romanian lyrics, Gentle is not a ready tool. The reference pipeline the reports named, synctoon (GPL-3.0), now defaults to word timestamps from ElevenLabs text-to-speech, keeps Gentle as an option, and uses an English grapheme-to-phoneme package for mouth shapes. A better fit for non-English audio is Rhubarb Lip Sync, which offers a phonetic recognizer that its README describes as language-independent and less precise than its English recognizer. It outputs six basic mouth shapes plus up to three optional ones, with timings as TSV, XML or JSON. We have not tested either tool on sung Romanian vocals over music, which is harder than speech. **Copyright: clones, folklore and Content ID** AI image models drift toward famous styles. Prompt for a simple 2D pig family and the result can look like Peppa Pig; prompt for big-headed 3D toddlers and it can look like CoComelon. Children's IP owners enforce actively. On 25 June 2026 the High Court of England and Wales granted summary judgment to Hasbro and its co-claimant against the Vietnamese producer of Wolfoo, finding that Wolfoo videos copied sound recordings from Peppa Pig. Expert spectrogram analysis found matches in all 92 pre-issue English videos tested. The claims about visual works, trade marks and goodwill were not decided in that judgment. A character sheet drawn or approved by a person, checked against well-known shows, is cheaper than a dispute. Traditional songs and carols are tempting because the compositions are often in the public domain, while specific modern recordings remain protected. YouTube's Content ID rules work against them: the uploader must have exclusive rights to the material in the reference file, and public domain recordings and compositions are listed as not eligible, as are non-exclusively licensed material, sound-alikes and full compilations. Original AI-assisted songs face a related problem. Since both Google and Suno say their tools may produce the same or similar output for other users, our reading is that claiming exclusive rights for Content ID would be risky. This does not block monetising your own channel. It does mean Content ID is unlikely to protect the catalogue. Lyrics and melodies written by a person are the part of the work a studio can most clearly call its own. **Where the reports were wrong** 1. Autoplay. The reports said YouTube disables autoplay by default for all users under 18, which makes compilations compulsory. YouTube's help page says autoplay is off by default for users aged 13 to 17; for younger children it is a parental setting. The made-for-kids list removes autoplay on the home feed only. 2. Gentle for Romanian. The reports presented Gentle as a ready CPU lip-sync tool. It ships English models, the language question has been open since 2017, and its Docker image dates from 2017. The pipeline they cited now defaults to ElevenLabs timestamps and English phoneme rules. 3. Wolfoo. Our materials described Wolfoo as a case the defendant won while being buried in legal costs. The English High Court ruled against the Wolfoo producer in June 2026 on copied sound recordings. Earlier press reports of a 2022 Moscow ruling in the producer's favour exist; we did not check them at a primary source. 4. Revenue figures. The livestream claim, $1.27 per 1,000 views for a 24/7 stream against $0.35 for videos, and the 50% to 80% drop for made-for-kids content both come from a company that sells 24/7 streaming of pre-recorded video. The Romanian $0.15 to $0.30 range came from a Reddit thread. None is a measured market figure. 5. Romania's influencer rules. One report put the CNA registration threshold at 500,000 followers. Romanian press coverage says CNA Decision 116/2026, in force since 27 September 2026, uses 100,000 followers or subscribers and at least 24 videos in the last 12 months, among other cumulative conditions, and that 500,000 was in an earlier draft. We could not open the official text. 6. Our own corrections. Our notes said Lyria should not be used through Google AI Studio because human reviewers read unpaid output. The Gemini API terms do allow human review on unpaid services, but they also say that for users in the EEA, Switzerland and the UK the paid-service data terms apply to all services, including AI Studio. Our notes also called the survival of Suno's commercial rights after cancellation unclear; the current terms say they survive. Two other claims from the reports, the removal of 16 AI channels in January 2026 and a three-strikes rule for undisclosed AI, could not be confirmed and are left out. **Method** We started from two AI deep-research reports on a Romanian AI-made children's channel, written in July 2026, and from our own notes and checklist built on them. We treated all four as leads, not sources. On 19 October 2026 we checked each claim used here against the primary source: Google's Cloud and Gemini API terms, Suno's terms of service, YouTube help pages on made-for-kids features, audience settings, autoplay, YouTube Kids parental controls, kids' quality principles, monetisation policies, altered content and Content ID, the FTC and DOJ releases and the FTC commissioners' statement on Disney, the Federal Register entry on civil penalty amounts, Article 28 of the Digital Services Act, the English High Court judgment in the Wolfoo case, the Fairplay letter, the GitHub repositories of Gentle, synctoon and Rhubarb Lip Sync, and the public pages of the three Romanian channels. For Mora TV's history and the CNA decision we could only reach press coverage, and we say so in the text. We could not verify: Mora TV's revenue figures, the January 2026 channel removals, the three-strikes disclosure rule, per-episode production costs, and audio streaming payouts for children's music. Those were dropped. **Limitations** The reading of Google's under-18 clause is an interpretation; only Google or a court can settle it, and a studio should get its own legal advice. Revenue-per-view figures are third-party estimates with commercial interests behind some of them. Channel statistics and terms of service change; the numbers here are as of 19 October 2026. We did not build or test the lip-sync pipeline, and we did not measure demand for the proposed content topics. The economic comparison between cartoons and songs rests on the feature restrictions and on the possibility of audio distribution, not on revenue data from channels like these. ### How do you make money from AI-generated music in 2026: YouTube, distributors, Spotify and collecting societies AI music can earn on YouTube, mostly from long compilations, as long as each video is disclosed as synthetic and is not mass-produced. Outside YouTube the routes are narrower: some distributors accept fully AI tracks but keep them out of Content ID, Spotify and Deezer limit recommendations, and collecting societies register only works with human authorship. The YouTube entry bar for new channels doubles on 1 February 2027. **The short answer** Fully AI-generated music can be monetized on YouTube. YouTube asks creators to disclose it as altered or synthetic content, and states that disclosure alone does not limit a video's audience or its eligibility to earn money. The real risk is the inauthentic content policy, which removes monetization from channels whose videos feel mass-produced or interchangeable. The format that earns is the long compilation, because only videos of 8 minutes or more can carry mid-roll ads. Outside YouTube, the map changed several times in 2026. DistroKid, RouteNote, LANDR and Amuse accept AI music with conditions, but the last three keep it out of YouTube Content ID. TuneCore accepts only tools trained on licensed data. CD Baby and Bandcamp refuse it. Spotify keeps AI-persona artists out of recommendations by default, and Deezer excludes fully AI tracks from recommendations and editorial playlists. Collecting societies register only works with a human author. This article is for people planning an AI music channel or catalogue. We checked each fact on 19 October 2026 at the platform, the distributor, the society, the court docket or the legal text. **YouTube: thresholds, inauthentic content and the AI label** Today a channel can apply to the YouTube Partner Program (YPP) with 1,000 subscribers plus either 4,000 qualified watch hours in the last 12 months or 10 million qualified Shorts views in the last 90 days. YouTube's help page on YPP changes states that from 1 February 2027 the entry thresholds for new creators change to 8,000 qualified watch hours in the last 365 days or 20 million qualified Shorts views in the last 90 days, still with 1,000 subscribers. The same page says channels already in YPP are not affected, but all partners must accept updated terms by 31 January 2027. From the same date, a channel needs 10 million qualified Shorts views over 90 days to earn from the Shorts creator pool; missing it does not remove the channel from YPP. The policy that matters most for AI music is the one YouTube renamed from repetitious content to inauthentic content on 15 July 2025. Its examples of content that cannot be monetized include image slideshows or scrolling text with minimal or no narrative, and AI-generated content made with generic templates that gives the impression of mass production. The consequence is set at channel level: a channel whose videos feel interchangeable cannot monetize. A music channel that pairs every generated track with the same static image is the textbook case. YouTube's disclosure page lists AI generated music among the things creators must disclose, and also content that creates music that is the main focus of the video. It states that disclosing AI content alone will not limit a video's audience or affect its eligibility to earn money. Creators who consistently fail to disclose may have a label applied by YouTube and may face penalties, including content removal or suspension from YPP. YouTube may also apply the label automatically when a file carries C2PA metadata, and the creator cannot change that label. **Content ID: why fully AI music stays out** Content ID is the system that lets a rights holder claim the revenue of other people's videos that use their audio. YouTube's eligibility rules require exclusive rights to the material in the reference file, and exclude content licensed non-exclusively, public domain recordings, sound-alikes and production loops. The rules do not mention AI. In practice the gate is held by distributors. RouteNote states that AI-generated content cannot be delivered to its content recognition options. LANDR and Amuse both list YouTube Content ID among the services that do not accept AI-generated music. Our reading is that two facts point the same way: purely AI output is unlikely to be protected by copyright, and the tools' terms usually allow similar output for other users, which makes exclusivity hard to claim. That is an interpretation, not a YouTube ruling. The consequence cuts both ways. A creator cannot use Content ID to stop re-uploads of fully AI tracks or earn from them. A streamer using such a catalogue is less exposed to claims routed through these distributors, but not immune: a generated track can still resemble a protected recording that is in Content ID. **Compilations, mid-rolls and what an RPM figure is worth** YouTube allows mid-roll ad breaks only on monetized videos of 8 minutes or longer. A three-minute song carries ads only before and after it, while a one-hour or three-hour compilation can carry many breaks. That is why sleep, focus and party compilations dominate monetized music channels, and why the inauthentic content policy matters most for exactly this format: a long compilation still needs visuals and curation that differ from video to video. Revenue per thousand views (RPM) is the weakest number in this field. For Romania, the data site Dynamoi showed an average creator RPM of $0.72 across all niches, with data to 15 October 2026, from aggregated YouTube Analytics with no sample size published. It gives no music-specific figure. Ranges circulating for lofi or sleep music on international audiences come from blogs and marketing pages; we found none we could trace to a measured source, so we give no figure for them. **Distributors in October 2026: who accepts AI music** DistroKid states that it accepts music created with AI tools, provided the uploader owns 100% of the rights, does not impersonate anyone, does not upload mass-generated spam and does not infringe. Its help page does not mention a Content ID restriction. RouteNote accepts AI-generated and AI-assisted music if the creator holds commercial rights, may ask for links to the tools used, and excludes AI content from content recognition, from the Korean partner stores Melon, Genie, Bugs, Flo and Vibe, and from Amazon. LANDR accepts AI-assisted music, requires disclosure at upload, uses detection, and caps AI-generated tracks per calendar month by plan: 20 on the basic tiers and 60 on the Pro tiers. It does not distribute AI covers, reserves the right to refuse music that relies entirely on AI audio generation, and lists YouTube Content ID, Meta, TikTok, Deezer, Lissen, Pandora and Tencent as platforms that currently do not accept AI-generated music. Amuse allows AI-generated music but rejects AI covers and imitations of known artists, and excludes Qobuz, Meta and YouTube Content ID. TuneCore, owned by Believe, distributes only music made with models trained on fully licensed datasets, even when AI is used for part of a track, and names Google's Flow Music as an approved partner. In April 2026 Believe began blocking tracks made with Suno's earlier models. On 8 September 2026 Suno and Believe announced a partnership under which tracks made with Suno's new industry-partner model become eligible for distribution through Believe and TuneCore; tracks from earlier Suno models remain outside. CD Baby does not accept any AI-generated content, even commercially licensed and even if only part of the recording is AI, and says it will remove such releases and may stop working with repeat uploaders. Bandcamp announced on 13 January 2026 that music generated wholly or in substantial part by AI is not permitted on the platform. **Spotify and Deezer: labels and recommendations** Spotify allows AI music. In September 2025 it announced a policy against unauthorized voice clones, a spam filter that stops recommending tracks using spam tactics, and support for a DDEX standard for AI disclosures in credits. AI credits are added through the distributor, show which part of a track was AI-generated, are optional and are in beta on mobile. Spotify also says it removed more than 75 million spam tracks in the preceding 12 months. On 11 August 2026 Spotify announced an AI Persona badge for artist profiles whose name and imagery depict an AI-generated identity rather than a real person, appearing from mid-September. By default Spotify does not include AI Personas in any editorial or algorithmic recommendation; listeners who follow the persona still see the music. Spotify says the badge is about the artist's public identity, not how the music was made, and that it reviews profiles itself, starting with those above audience thresholds. A real artist who discloses AI use in credits is not covered by the badge. Two older Spotify rules shape functional music. A track needs at least 1,000 streams in the previous 12 months, plus an undisclosed minimum of unique listeners, to earn recorded-music royalties. Spotify also says background and production music has tended to command lower royalty rates, and that it values noise recordings at a fraction of music streams. Deezer has tagged AI-generated music since June 2025. Fully AI-generated songs are removed from algorithmic recommendations and not included in editorial playlists. In October 2026 Deezer reported more than 42 million AI tracks excluded from recommendations since January 2025, close to 90,000 fully AI tracks delivered per day, which at peak exceeded half of daily deliveries, and a share of 1% to 3% of streams. Up to 85% of streams on fully AI tracks were fraudulent in 2025; Deezer demonetizes the fraudulent streams, not AI music as such, and says demonetizing content would need careful consideration. It plans a listener toggle to hide fully AI music from search before the end of 2026. **Copyright and collecting societies: the human part decides** The US Copyright Office's January 2025 report on copyrightability concludes that prompts alone do not give users enough control to be authors of the output, and that content entirely generated by AI cannot be protected. Human expression that is perceptible in the output, and the creative selection and arrangement of material, can be protected. The report cites the Randy Travis recording Where That Came From, registered because an AI vocal model was used as a tool to realize a human performance, not to generate expression. Romanian law defines the author as the natural person or persons who created the work. ASCAP, BMI and SOCAN announced on 28 October 2025 that they accept registrations of partially AI-generated musical works, defined as works combining AI-generated content with human authorship, and that these works join the repertoires each society licenses. Works created entirely with AI tools cannot be registered. The announcement does not set out payment terms. In Romania, UCMR-ADA's 2025 declaration form for musical works asks the composer to declare, under the sanction of criminal law, that the work is their original creation and is not generated by artificial intelligence. The form does not define how much AI involvement makes a work AI-generated. Whether a song with human-written lyrics and melody but AI-produced arrangement passes that declaration is an interpretation that should be checked with the society before filing. **The AI Act and the lawsuits** Article 50 of the EU AI Act applies from 2 August 2026. Article 50(2) obliges providers of systems that generate synthetic audio to mark outputs in a machine-readable, detectable format, which falls on the model provider. Article 50(4) obliges deployers of deep fakes to disclose them, and the Act defines a deep fake as content that resembles existing persons, objects, places, entities or events and would falsely appear authentic. Our interpretation is that generic AI music without a resemblance to a real singer is unlikely to be a deep fake, while a cloned real voice would be; this is not settled guidance. In Kogon v. Google, filed on 6 March 2026 in the Northern District of Illinois, independent musicians allege that Google trained Lyria 3 on their music from YouTube. Google moved to dismiss on 8 June 2026; the judge allowed a sur-reply on 27 July, and the docket we checked showed no ruling. The case targets Google, not users of the model. UMG and Sony's 2024 case against Suno in Massachusetts is still open, and on 18 September 2026 they filed a second case there over Suno's newer models. Udio entered partnerships with Universal on 29 October 2025 and later with Warner; Udio's help centre says downloading audio, video and stems has been disabled. Sony filed a new case against Udio's parent company in New York in July 2026. **Niches that pay and doors that are closed** Functional YouTube music (sleep, lofi, focus) is the most repeatable model, because long watch sessions fill mid-roll slots. It works only with visuals and curation that make each video distinct; otherwise it is the pattern the inauthentic content policy describes. On Spotify the same content runs into the 1,000-stream threshold and lower functional rates. Background music for shops in Romania. Public communication of musical works is under extended collective management, and the single equitable remuneration for commercial phonograms is under mandatory collective management, where the societies represent rights holders who never gave them a mandate. The law also says fees may be claimed only where protected works are used. Providers already sell catalogues marketed as free of these fees. Our interpretation is that a fully AI catalogue may avoid the author fee, since there is no human author, but it is not clear that a recording escapes the phonogram producers' remuneration, which does not depend on authorship. Anyone selling this needs a legal opinion first. Catalogues for streamers. Because fully AI tracks are kept out of Content ID by the distributors above, a licensed catalogue is less likely to trigger claims on monetized streams and VODs. It is a smaller risk, not zero. Closed: stock music marketplaces. Pond5 states it cannot accept AI-generated content from contributors because they must own what they upload. Persona-led vocal acts now face the Spotify AI Persona exclusion. In Romania, the best-known AI persona, Lolita Cercel, drew wide press coverage and criticism from Roma activists in early 2026; on 19 October 2026 its YouTube channel showed 34.7K subscribers, its most-viewed video about 5 million views, and no new song since 14 January 2026, with a Bucharest Tech Week performance posted in June. **Where the reports were wrong** 1. The YPP doubling. Our own notes said the YouTube threshold doubles on 1 February 2027, and some readings implied it applies to everyone. YouTube's help page confirms 8,000 watch hours or 20 million Shorts views, but only for new applicants; current partners keep their status if they accept the new terms. 2. TuneCore blocks Suno. True from April 2026, outdated since 8 September 2026: Suno's new industry-partner model is eligible through Believe and TuneCore, earlier Suno models are not. The claims of a 99% per-model detection rate and a pirate-studio label could not be traced to a primary source. 3. Distributor details. Our notes gave LANDR a cap of 12 AI tracks per month; LANDR's page shows 20 or 60 by plan. Amuse was listed among distributors that forbid AI; its help page allows AI music with exclusions. One deep-research report said CD Baby accepts AI-assisted music with proof of human input; CD Baby refuses even partly AI recordings. 4. Deezer. Our notes said 85% of AI streams are fraud and demonetized. Deezer's figure is up to 85% of streams on fully AI tracks in 2025; it demonetizes the fraudulent streams and says it has not demonetized AI content as such. 5. Payment and RPM. Our notes said ASCAP, BMI and SOCAN pay partially AI works identically; the announcement says they join the licensed repertoires and says nothing about payment. A deep-research report gave Romania a $1.15 average RPM citing Dynamoi; the same page now shows $0.72. A boost of 40% to 100% RPM from mid-rolls had no source. 6. The AI Act. One report said Article 50(4) obliges a Romanian company to disclose any AI music and threatened fines of up to 15 million euros. Article 50(4) covers deep fakes as the Act defines them; applying it to generic music is an interpretation, and the fine was not tied to this case in any source we read. **Method** We started from two deep-research reports produced by AI engines, a research dossier and our own project notes on AI music channels. We removed internal figures and product decisions, kept the claims a creator would act on, and checked each on 19 October 2026: YouTube help pages on YPP, YPP changes, inauthentic content, AI disclosure, Content ID eligibility and mid-roll ads; the AI policies of DistroKid, RouteNote, TuneCore, LANDR, Amuse, CD Baby and Bandcamp; Suno's announcement of the Believe partnership; Spotify's newsroom and artist support pages; Deezer's press releases of January and October 2026; the ASCAP, BMI and SOCAN joint release; UCMR-ADA's declaration form; the US Copyright Office report; the AI Act and Romanian Law 8/1996 on legal portals; Udio's help centre; Pond5's contributor FAQ; and the public court dockets for the Google, Suno and Udio cases. Channel figures were read from YouTube on the same day. Removed because we could not verify them at a primary source: Spotify listener figures for Lolita Cercel, view counts for other Romanian AI channels, revenue figures for AI persona acts, RPM ranges for lofi and sleep music, the Songtradr marketplace closing date, Amuse's cancellation terms, distributor prices, a January 2026 removal of AI channels from YouTube, and claims about Spotify ghost artists. **Limitations** Platform and distributor policies changed several times in 2026 and will change again; every rule above is a snapshot of 19 October 2026. Help pages say what a company intends, not how consistently it enforces it. The legal points on Content ID, the UCMR-ADA declaration, Romanian shop fees and Article 50 are our reading of the texts, not legal advice. The litigation is ongoing and no court has ruled on whether training music models on copyrighted recordings is lawful in these cases. Revenue figures are deliberately absent because we found none we could trace to a measured source for this niche. ### Can Google Lyria 3 sing in Romanian and play Romanian genres? What we measured through the Gemini API Romanian is not on Google's list of supported vocal languages for Lyria 3, but in our tests it worked: about 85% of the words we sent came back as written, diacritics were pronounced correctly, and the intonation was odd in places. Functional instrumentals such as lofi, sleep and ambient worked every time, while Romanian dance names like sârbă did not: the model needs the rhythm described in musical terms. The list price of $0.04 or $0.08 per song understates the real cost of a usable vocal track, because there is no iterative editing. **The short answer** Lyria 3 is Google's music generation model family in the Gemini API. It comes in two versions: lyria-3-clip-preview for 30-second clips and lyria-3-pro-preview for full songs. Google lists eight vocal languages for Lyria 3, and Romanian is not one of them. In our tests Romanian still worked: the model sang real Romanian words, pronounced the diacritics correctly and kept roughly 85% of our lyrics intact. The voice had odd intonation in places. Functional instrumentals came out well every time we tried them. Romanian folk dance names did not: asked for a sârbă, the model played a horă both times. Describing the rhythm in musical terms is the workaround. For vocal songs with fixed lyrics, the real cost per usable track is on the order of ten times the list price, because a bad verse means regenerating the whole song. One change since our tests matters. On 3 September 2026 Google released lyria-3.5 as generally available, and the documentation now calls Lyria 3 a legacy family. Our measurements were made on Lyria 3 and may not carry over to Lyria 3.5. **Which models exist today** Google launched Lyria 3 in the Gemini API on 25 March 2026 with two models: lyria-3-clip-preview for 30-second clips and lyria-3-pro-preview for full-length songs. Both are still listed as preview models, and Google's deprecations page shows no shutdown date for either. On 3 September 2026 Google announced lyria-3.5 as generally available, with improved musical coherence, natural vocals and finer control of duration and structure. The models page now describes Lyria 3 Pro as the previous generation, and the deprecations page names lyria-3.5 as its recommended replacement. The clip model has no newer version: lyria-3-clip-preview is still the 30-second option in the current guide. Pricing on Google's page is per request. Lyria 3 Clip Preview costs $0.04 per song, Lyria 3 Pro Preview costs $0.08 per song, and Lyria 3.5 costs $0.08 per song. There is no free tier for any of them. **What the documentation promises** Length. The clip model always produces 30 seconds. Google's Cloud blog says Lyria 3 Pro supports compositions up to three minutes. The longest full song we generated ran 2 minutes 25 seconds. Languages. Google's April 2026 prompting guide for Lyria 3 lists eight vocal languages: English, German, Spanish, French, Hindi, Japanese, Korean and Portuguese, with more languages coming soon. The current Lyria 3.5 guide no longer gives a list. It says the model writes lyrics in the language of the prompt and adapts its vocal style and pronunciation to that language. Watermark. All generated audio carries a SynthID audio watermark, according to the music generation guide. The Cloud blog adds that outputs support C2PA content credentials. Editing. The guide says plainly that music generation is a single-turn process, and that iterative editing or refining a result through further prompts is not supported. Results can also differ between calls with the same prompt. Filters. Every prompt goes through safety filters, including checks for requests for specific artists' voices and for copyrighted lyrics. Prompts that trigger the filters are blocked. **Romanian vocals: what we measured** We sent Romanian lyrics to lyria-3-pro-preview and compared what was sung with what we wrote. About 85% of the words came back as written. The rest were replaced by words that do not exist or were not in our text. Words with ș, ț, ă, â and î were pronounced correctly when we sent them with diacritics. Our listener rated the voice as clearly synthetic in places, with stresses landing on the wrong syllable. We also compared two ways of writing the lyrics: normal Romanian with diacritics, and a phonetic spelling based on English reading rules, for example chass for ceas and dor-u-le with hyphens to force syllables. One track in each version was judged good, both with the same remark about odd intonation in places. The A/B had no clear winner, so we now start with plain diacritics and only respell the specific words that come out wrong. Two habits helped. We kept each lyric block to four lines or fewer, and we put the lyrics after a Lyrics: header, which Google's own guide recommends, so the model does not sing the stage directions. Shouted interjections, the strigături typical of Romanian party music, came out natural. **Romanian dances: describe the rhythm, not the name** Google's genre keyword list for Lyria includes polka, cumbia, bhangra and Celtic folk, but no Romanian genre. Our results fit that gap. Asked for a sârbă, the model delivered a horă twice out of two attempts. A horă asked for by name had an odd rhythm, missing the repeating swaying cycle that makes the dance. A lăutărească sounded right in timbre but lacked the usual arc of the genre, which builds and speeds up section by section. The model seems to map the dance names onto a generic folk feel. The fix is to describe what a musician would hear. These are the formulations we now use. They come from our listener's description of what was missing; we have not yet measured them in a second batch. Sârbă: very fast, driving dance around 150 BPM, relentless off-beat oom-pah accompaniment, continuous rapid running melody, no tempo changes. Horă: moderate circle dance around 100 BPM, cyclical swaying phrase structure that rocks forward and back, strong repeating rhythmic cycle. Lăutăresc dance piece: clear dramatic progression: slow expressive rubato intro, then a moderate groove, then accelerating section by section to a frenzied virtuosic finale. We left time signatures out on purpose. Written descriptions of these dances disagree on the meter, and we have not tested which notation the model follows better. **Genre decides how synthetic the voice sounds** The functional genres were the easy case. All eight lofi, sleep and ambient instrumentals we generated were judged usable. There is no voice to expose, and the genres tolerate repetition. With vocals, genre mattered more than the language. A ballad came out as a genuine ballad, but it is the hardest test for a synthetic voice: the slow tempo and exposed singing leave every odd stress and missing breath audible. A manea with Romanian lyrics sounded acceptable because heavy auto-tune and reverb are the norm in that genre, and the same processing hides the model's artefacts. We expect the same for trap, where auto-tune is also part of the aesthetic; that is a judgement from our listening, not a measurement across genres. **What a usable track really costs** The list price is the cost of one generation, not of one usable song. Because there is no iterative editing, a single bad verse means throwing the whole track away and generating it again at full price. In our sessions, vocal songs with fixed Romanian lyrics needed on the order of ten attempts for one we would keep, so the real cost per usable track was roughly ten times the list price. Functional instrumentals were the exception: there, the first take was usually usable. Lyric changes are the second cost. When a word is sung differently from what we wrote, the API returns a normal successful response and the request is billed like any other. There is no warning. The only check is to listen to the whole track against the text. Our notes describe whole lines being replaced by generic ones when lyrics touched a filter; Google's documentation only says that prompts triggering the filters are blocked, so we could not confirm that mechanism. With Lyria 3.5 the response also includes the generated lyrics as text, which makes a first automatic comparison possible. Google's own guide suggests a cheaper loop: test prompts on the 30-second clip model, at half the price, before committing to a full song. We do the same for genre and rhythm wording. **Rate limits, terms and why we use the paid API** Google publishes no Lyria-specific rate limit. Its rate limit page says limits apply per project, can be checked in AI Studio and are tighter for preview models. Our project ran at about 10 requests per minute, so batches need a pause and retry. The Gemini API terms require users to be 18 or older, and forbid using the service in a site or app directed at, or likely to be used by, people under 18. In our reading, that rules out building a music generator for a children's app on this API. It is our interpretation, not legal advice. On data, the terms separate unpaid from paid services. With unpaid services, such as AI Studio and free API quota, Google may use prompts and outputs to improve its products, and human reviewers may read them. With paid services, Google does not use prompts or responses to improve its products and keeps them only for a limited time for abuse monitoring. Since Lyria has no free tier, every API call is already paid. We still generate through the API with a billing-linked project rather than in AI Studio, so that commercial lyrics stay under the paid terms. **Putting generated music on a video** We use Lyria in a small internal tool that scores short videos. The flow is generic: the tool samples a few frames from the video, a multimodal Gemini model proposes a genre, mood, tempo and instruments, the user adjusts them, Lyria generates the track, and ffmpeg replaces the original audio. Videos up to 30 seconds use the clip model; longer ones use the full-song model. If the music is shorter than the video it loops; if longer it is cut to the video's length with a fade at the end. Two lessons came from it. Our users filled in the fields in Romanian, and since the current guide says Lyria writes lyrics in the language of the prompt, we translate the prompt to English before sending it. The first version of the translation step dropped the words instrumental, no vocals, which would have put singing over the video. We now add them back after translation if they are missing. **Where we were wrong** 1. The list price as a cost estimate. We first budgeted vocal songs at the list price of $0.08. Without iterative editing, and with silent lyric changes, the real cost per usable vocal track was about ten times that. The list price only holds for instrumentals that work on the first take. 2. The dance name as the prompt. We assumed that writing sârbă, horă or lăutăresc in the prompt was enough. It was not: sârbă came out as horă twice out of two. Musical description of tempo, accompaniment and structure is what the model can follow. 3. Phonetic spelling as the fix. Our working notes recommended respelling Romanian lyrics phonetically to fix diction. In the A/B, plain diacritics did just as well. Respelling is now a fix for specific words, not the default. 4. Our own dance notes. Our first wording for the sârbă specified a 2/4 meter, and our listener described the horă as three steps forward and three back. Published descriptions give other meters and step patterns. We removed both details from the prompts rather than guess. 5. AI Studio as the data risk. Our notes said to avoid AI Studio because human reviewers can read output. That is true for unpaid services in general, but the terms also say that AI Studio counts as a paid service when the account is linked to an active billing project, and that in the EEA, Switzerland and the UK the paid-service data terms apply to all services. For a Romanian company the gap is smaller than we wrote. 6. Audio format. Google's March 2026 changelog describes Lyria 3 output as 48 kHz stereo. Every file we received was a 44.1 kHz stereo MP3, which matches the current guide. **Method** We generated tracks with lyria-3-pro-preview and lyria-3-clip-preview through the paid Gemini API between July and August 2026: functional instrumentals (lofi, sleep, ambient) and Romanian party genres (sârbă, horă, lăutăresc, manea, folk song with strigături, ballad), some with Romanian lyrics written by us. One listener familiar with Romanian folk and party music judged each track. Lyric fidelity was counted by comparing the sung words with the submitted text. Audio format and duration were read from the files with ffprobe. We checked every platform fact against Google's own pages on 19 October 2026: the music generation guide, the Lyria prompt guide, the pricing page, the models page, the deprecations page, the changelog, the rate limit page, the Gemini API additional terms, and the Google Cloud prompting guide for Lyria 3 that lists the vocal languages. We could not verify, and therefore dropped or flagged: a specific rate limit for Lyria, the mechanism behind silent lyric replacement, and claims in our notes about IP indemnification and ongoing litigation, which are outside this measurement. **Limitations** The samples are small: eight functional tracks, two sârbă attempts and one or two tracks per other genre. The quality judgements come from one listener and are subjective. The 85% fidelity figure comes from our Romanian lyric tests, not from a large corpus. The diacritics versus phonetic A/B compared one track each, in two different genres, so it can only show that neither method failed. The prompt wordings for sârbă, horă and lăutăresc have not yet been tested in a second batch. All tests ran on Lyria 3, before Lyria 3.5 was released; the newer model may handle Romanian and its dances differently. Preview models can change without notice, and the cost multiplier depends on how strict the listener is. ### How to process your own vocal over an AI instrumental with free Python tools on a laptop without a GPU Clean the recording first, then correct pitch, then shape tone and dynamics, then add space, and master last. Each step has an open-source Python tool that runs on a CPU: DeepFilterNet for noise, a BS-RoFormer model for room echo, torchcrepe with pyworld for pitch correction that keeps your timbre, pedalboard for effects and Matchering for reference mastering. Processing fixes wrong notes, not timing or diction, and singing the vocal yourself changes who owns the result and what YouTube asks you to disclose. **The short answer** If you sing over an instrumental made by an AI music generator, you can do the vocal production without a DAW, in Python, on a laptop with an Intel processor, no dedicated GPU and 16 GB of RAM. The chain runs in a fixed order: restoration (noise, then room echo), pitch correction, tone and dynamics, spatial effects, mastering. Every stage has an open-source tool whose repository or model page we checked on 19 October 2026. Two things the tools cannot do. They cannot move syllables that were sung off the beat, and they cannot fix how you form vowels. Voice conversion, which swaps your timbre for another voice, is not worth running on a CPU, and the ready-made voice models on public hubs are mostly named after real singers and characters. On the legal side, singing yourself gives you rights in the lyrics, the melody and your performance, while the AI instrumental stays a separate question, and YouTube treats your own vocals over an AI-generated layer as partly generated content. **Why the order of the chain matters** The order is restoration, then pitch, then tone and dynamics, then space, then mastering. It follows from what each stage needs as input. A pitch tracker reads the waveform to decide which note you sang. Background noise and room reflections give it a second, false signal to follow. The CREPE paper, which describes the model behind torchcrepe, tests pitch trackers with added noise precisely because accuracy drops when noise is present. Compression before restoration makes things worse. A compressor reduces the gap between loud and quiet parts, so it raises the noise between phrases along with the voice. Reverb before pitch correction smears each note into the next, and the tracker then corrects the tail of the echo as if it were singing. Mastering comes last because it reacts to the whole mix, vocal and instrumental together, and any change upstream changes what the limiter does. Swapping two stages does not break the script, but it gives each tool a worse input than it was built for. **Restoration: removing noise and room echo** For noise, DeepFilterNet is the tool we found best documented for CPU use. The repository is dual-licensed MIT or Apache 2.0, installs with pip install deepfilternet after PyTorch, and processes full-band audio at 48 kHz. The paper that introduces DeepFilterNet3 reports a real-time factor of 0.19 on a single thread of a notebook Intel i5-8250U, using a Rust runtime, with 40 ms of latency. A real-time factor of 0.19 means a three-minute file takes well under a minute. On the VoiceBank+DEMAND test set the paper gives DeepFilterNet3 a STOI intelligibility score of 0.944. For room echo, the reports pointed to dereverb_bs_roformer, a Band-Split RoFormer model published on Hugging Face. The model card is empty apart from its licence, GPL-3.0; the files are a config and one checkpoint whose name carries an SDR of 22.5050, a figure with no stated test set. The inference package bs-roformer-infer (MIT) lists a de-reverberation model built on that config and loads weights on the CPU in its example. It does not state CPU speed. Transformer models of this kind are heavier than DeepFilterNet, so expect to process offline, file by file. Both models were trained or described for speech or music separation, not specifically for a solo singing voice recorded at home. They work best on a recording that is already reasonably clean, which is why the recording advice later in this article matters more than any setting. **Pitch correction that keeps your timbre: torchcrepe and pyworld** Automatic pitch correction in code has two parts: find the note you sang, then move it to the nearest note of the song's scale. For the first part, torchcrepe (MIT licence) is a PyTorch implementation of CREPE, a neural network that reads the raw waveform. In the original paper CREPE outputs 360 pitch bins of 20 cents each, covering six octaves, and matches or beats pYIN, the classic algorithm in librosa, on two synthesised datasets; the gap is largest at a strict 10-cent tolerance. torchcrepe decodes with Viterbi by default, which favours a smooth pitch path over isolated jumps. For the second part, pyworld (MIT licence) wraps the WORLD vocoder, which splits a voice into three parameters: the pitch contour (F0), the harmonic spectral envelope (SP) and the aperiodic envelope (AP). Pitch correction then means replacing F0 with the snapped notes and resynthesising with the original SP and AP. The spectral envelope carries the formants, the resonances of your mouth and throat that make the voice sound like you, so leaving it untouched keeps your timbre while the notes change. Because SP is a separate parameter, you can also shift it on purpose, which is how the darker, thicker trap vocal is made. "Hard" autotune, the robotic effect heard in manele and trap, is the same operation with no glide: each frame's F0 jumps straight to the target note instead of easing towards it. In commercial plug-ins this is a retune speed near zero milliseconds. **Effects and mastering in code: pedalboard and Matchering** pedalboard, published by Spotify, is a Python library of studio effects: compressor, distortion, delay, reverb, filters, limiter, pitch shift, and hosting for VST3 plug-ins (and Audio Units on macOS). It is licensed GPL-3.0, releases Python's GIL so it can use several CPU cores, and ships wheels for Intel and Apple Silicon Macs, x86-64 Windows and Linux. A typical vocal chain is high-pass filter, compressor, light saturation, then delay and reverb. One detail matters: pedalboard's HighpassFilter is first-order, 6 dB per octave, and its argument is cutoff_frequency_hz; a single one at 100 Hz removes much less low rumble than a studio high-pass would. Matchering 2.0 (GPL-3.0) masters by reference. You give it your mix and a commercial track you want to sound like, and it returns your mix with the same RMS loudness, frequency response, peak amplitude and stereo width as the reference, finished by its own brickwall limiter, Hyrax. The repository asks for Python 3.8 or later and 4 GB of RAM, and needs FFmpeg only to read MP3 files. It mentions no GPU. The reports suggested starting values by genre. We could not trace them to a primary source, so treat them as a place to begin, not a recipe. For manele: a Phrygian dominant scale (the fifth mode of harmonic minor) or harmonic minor, retune within 0-5 ms, Compressor(threshold_db=-25, ratio=6, attack_ms=2, release_ms=150), a tempo-synced Delay(feedback=0.45, mix=0.25) and a hall-like Reverb(room_size=0.8, damping=0.4, wet_level=0.3). For trap: natural minor, retune at 0 ms, the spectral envelope shifted down 1-3 semitones and Distortion(drive_db=10). We installed pedalboard 0.9.26 and checked that these argument names exist; the drive value is below the library's default of 25 dB. **Why voice conversion is not worth it on a CPU** Voice conversion replaces the timbre of a recording with another voice. Seed-VC (GPL-3.0) does it without training, from a reference clip of 1 to 30 seconds. Its singing model has 200M parameters, runs at 44.1 kHz and uses diffusion: 25 steps by default, with 30-50 recommended for best quality. The repository strongly recommends a GPU for real-time use and benchmarks on a laptop RTX 3060 GPU; it says nothing about CPU speed. We did not time it on a CPU, but tens of diffusion steps through a 200M-parameter model for every chunk of a three-minute song is the slowest stage of any chain described here by a wide margin. RVC (MIT licence) needs a model trained on your own recordings first; its README recommends at least 10 minutes of clean, low-noise voice. Training is designed around a graphics card, with a CPU route listed for machines without an NVIDIA card. For an untrained singer the deeper problem is logical: converting your weak take into your own voice copies the same breath, wobble and diction from the reference. Pitch correction with torchcrepe and pyworld changes what actually needs changing, at a fraction of the cost. The shortcut, downloading someone else's model, is where the risk sits. On 19 October 2026 a Hugging Face search for "rvc" returned 3,513 models. Among the 30 most downloaded, several were named after well-known singers, a politician and cartoon characters. Using a real person's voice raises consent and image-rights questions that we do not try to settle here, and YouTube's disclosure rules separately require you to label realistic content that makes a real person appear to say or do something they did not. **What processing fixes, what it does not, and how to record** What gets fixed: notes sung off-pitch but held steadily, background noise, and much of the room echo. What gets better but not fixed: a thin voice, which compression and saturation make denser. What does not get fixed: syllables that are early or late against the beat, because no automatic stage in this chain moves them; diction, because the formants pyworld preserves are the shape of your vowels, nasal or strained ones included; and vocal fry or breathy phrase ends, where there is no stable pitch for the tracker to follow and the output glitches. In practice: sing the line straight, hit each note directly, keep consonants crisp, and record several takes per line so you can pick the best words. For the room, the closet full of clothes is a poor booth. A porous absorber works when it is roughly a quarter of a wavelength thick. At 200 Hz, in the range of a voice's fundamental, a quarter wavelength is about 43 cm, more than a row of shirts. The clothes damp the highs and leave the low-mid boom of a small box. A better free setup is the middle of the largest room, away from corners, with heavy blankets or a mattress behind the singer: a cardioid microphone is least sensitive at its back, which points away from you, and most sensitive at its front, which points at you and at the wall behind you, so that is the reflection to absorb. For gear, a dynamic microphone into a small USB audio interface is a better start than a cheap USB microphone in an untreated room, because a dynamic capsule picks up less of the room. Common choices are a Behringer XM8500 or a Shure SM58 with a two-input interface such as the Behringer UMC202HD. Prices change; at Romanian retailers a starter set is in the order of several hundred lei, roughly up to one thousand with a stand and an XLR cable. **Who owns a song you sang over an AI instrumental** Under Romania's copyright law, Law 8/1996, the author is the natural person who created the work (Art. 3), and musical compositions with or without lyrics are protected works (Art. 7). If you wrote the lyrics and the vocal melody, those are yours. Singing them adds a separate layer: the law protects singers as performers (Art. 95) with moral and economic rights in their performance, and gives the producer of a sound recording, the person who takes the initiative and pays for the first fixation of the sounds, a related right in that recording (Art. 104). Recording at home on your own initiative fits that description. How those rules apply to the AI-generated instrumental the law does not say, and that is a matter for a lawyer, not for this article. The US Copyright Office drew the line in its January 2025 report on copyrightability: copyright does not extend to purely AI-generated material, prompts alone do not make the user an author, and using AI as an assistive tool does not by itself affect protection for the work as a whole. Its example is Where That Came From, a Randy Travis track registered as a sound recording (SR0001018989, 29 May 2024). The production team took a recording of another singer, James Dupré, and used an AI vocal model trained on Travis's past recordings to render it in Travis's voice while keeping Dupré's phrasing and dynamics. The Office registered it because AI was used "as a tool, not to generate expression". That is a US decision and does not bind Romanian courts, but it is the clearest official statement we found that processing a human performance is not the same as generating one. **What YouTube asks you to disclose** YouTube's general disclosure page asks creators to flag realistic generative AI content, including AI-generated music that is the main focus of a video. Its list of changes that need no disclosure includes voice or audio repair, using effects to enhance previously recorded audio, and cloning one's own voice to create voice-overs or dubs. Denoise, dereverb and pitch correction on your own voice fall under those examples. YouTube's separate page for music content is more precise. It has three declarations: fully, partly or not generated. An artist who generates one or more structural layers of a song with AI and records their own vocals over it is its example of partly generated. Standard pitch correction such as Auto-Tune and automated AI mastering are its examples of no generative AI. The pitch-correction example speaks of fixing minor tuning mistakes; hard autotune used as an effect is not named, though the general page exempts effects that enhance recorded audio. So the vocal processing needs no flag, and the AI instrumental does. YouTube states that disclosure alone will not limit a video's audience or its eligibility to earn money; for AI-generated music, the label may appear in the video player. Creators who consistently fail to disclose can face penalties up to suspension from the YouTube Partner Program. **Where the reports were wrong** 1. DeepFilterNet3's numbers. One report credited DeepFilterNet3 with a STOI above 0.95, about 2.3 million parameters, five times real-time on a high-end laptop CPU and a clear win over RNNoise. The DeepFilterNet3 paper gives STOI 0.944, a real-time factor of 0.19 on a single thread of an i5-8250U with a Rust runtime, and contains no parameter count or RNNoise comparison. We kept only what the paper states. 2. The pedalboard code. The report wrote HighpassFilter(cutoff_hz=100). The argument is cutoff_frequency_hz, and the filter is first-order, so a single 100 Hz filter is gentler than the text implied. 3. PSOLA and the chipmunk effect. The report said PSOLA-based shifting distorts formants and produces a chipmunk sound above two semitones. The psola package page, which wraps Praat's TD-PSOLA, makes no claim either way, and we found no primary source for the two-semitone threshold. The chipmunk effect is characteristic of shifting by resampling. The real reason to prefer pyworld is that it exposes the spectral envelope as a separate parameter you can keep or shift on purpose. 4. Rights under Romanian law. The report gave the singer only phonogram-producer rights on the isolated vocal track. The law also protects the singer as a performer (Art. 95), and its definition of a sound recording (Art. 104) covers the fixation of "other sounds", not only a performance. How far that reaches over an AI instrumental is open; the report's narrower framing is not what the text says. 5. YouTube labelling. The report placed the AI label in the description. YouTube's page says that for AI-generated music it may appear in the video player. The music disclosure page, which the reports did not cite, classifies your own vocals over an AI-generated layer as partly generated. 6. Open source is not one licence. Our materials called the whole stack open source without noting the difference. DeepFilterNet (MIT or Apache 2.0), torchcrepe, pyworld and RVC (MIT) are permissive; pedalboard, Matchering, Seed-VC, the dereverb checkpoint and the psola package are GPL-3.0. Our reading, not legal advice: the GPL governs distributing the software, not the audio you make with it, but it matters if you ship these tools inside an app. 7. Advice that contradicted itself. Our own earlier notes recommended a clothes closet as a vocal booth, a USB microphone, and Seed-VC as the one voice option without a GPU. The later research and the repositories say otherwise: the closet colours the low mids, a dynamic microphone into an interface rejects more of the room, and Seed-VC's maintainers recommend a GPU. **Method** We started from an AI deep-research report on hybrid vocal production on a CPU, written in Romanian, and from our own project notes, which already listed corrections to earlier advice. We removed the pitch for any tool or service, kept the claims a home producer would act on, and on 19 October 2026 checked each one: the GitHub repositories of DeepFilterNet, torchcrepe, pyworld, pedalboard, Matchering, Seed-VC and RVC; the Hugging Face page and file list of the dereverb model; the DeepFilterNet3 and CREPE papers on arXiv; a Hugging Face model search; the US Copyright Office's January 2025 report; the consolidated text of Law 8/1996 on the Romanian legislation portal; and two YouTube Help pages on AI disclosure. We also installed pedalboard and read its constructor signatures. Dropped because we could not verify them at a primary source: DeepFilterNet3's parameter count and the RNNoise comparison, CPU timings for the dereverb model and Seed-VC, the claim that pYIN systematically misses octaves on sung voice, the two-semitone PSOLA threshold, the genre parameters as industry standards, and exact equipment prices. **Limitations** This is desk research, not a listening test. We did not run the full chain on a recorded vocal or time each model on a CPU, so the speed comments rest on the papers and repository notes, and on the size of the models. The genre settings are starting points from a report, not measured values. The models were built mostly on speech or general music data, and results on a solo singing voice recorded at home will vary. The legal sections describe what the texts say; how they apply to a given song, especially to the AI instrumental, is a question for a lawyer. YouTube's disclosure rules and the repositories' licences can change, so check them again before you publish or ship anything. ### Which characters can you legally use in an AI video channel on YouTube from Europe? Characters whose source works are out of copyright in both the EU and the US: Dracula, the Greek and Norse myths, Frankenstein's creature, the Grimm and Andersen tales, Pinocchio, Alice, Oz, Sherlock Holmes and similar figures, plus SCP Foundation material under its CC BY-SA licence. The source text is free; the famous film design, modern translations and registered trade marks often are not. The 2023-24 format of AI slideshows built on studio characters fails on both fronts: YouTube's monetisation rules and the studios' lawsuits against AI image companies. **The short answer** From Europe, the characters you can build a monetised AI video channel on are the ones whose source works are out of copyright in both the EU and the US. In the EU, a work is protected for the author's life plus 70 years. In the US, works published before 1978 are protected for at most 95 years from publication. A character that passes both tests includes Dracula, Odysseus and the Greek gods, Thor and the Norse myths, Frankenstein's creature, the Grimm and Andersen tales, Pinocchio, Alice, Dorothy and the Oz cast, Peter Pan from Barrie's book, Sherlock Holmes, Sun Wukong, Robin Hood, King Arthur, Don Quixote, the figures of the Thousand and One Nights and anonymous folklore, including Romanian folklore. Three traps sit on top of that list. The character is free but the famous screen design is not, and image models reproduce that design without being asked. A character can be out of copyright and still be a registered trade mark. And public domain status differs between the EU and the US, so a character that is free in the US can still be protected where you work. This article explains each, with the dates checked against the statutes, court decisions and the EU trade mark register. It is desk research, not legal advice; where a point is interpretation, we say so. **Why the 2023-24 slideshow format stopped working** The wave that followed the "Harry Potter by Balenciaga" videos used AI still images of famous protected characters, cut into slideshows, often titled "X but in Y". Two things changed since then. On 15 July 2025, YouTube renamed its "repetitious content" monetisation policy to inauthentic content. YouTube called it a minor update. The policy text lists, as not allowed, "image slideshows, templated storylines, or scrolling text with minimal or no narrative, commentary, or educational value" and "AI-generated content made with generic or unoriginal templates giving the impression of mass production". The review applies to the channel as a whole, under the YouTube Partner Program, and is separate from copyright enforcement. The second change is legal. On 11 June 2025, Disney, Marvel, Lucasfilm, 20th Century and Universal with DreamWorks sued Midjourney in the US District Court for the Central District of California (case 2:25-cv-05275). The complaint names Darth Vader, Elsa, Buzz Lightyear, Shrek, the Minions and Homer Simpson, and says that a simple text prompt naming a character returns an image of it. It also targets Midjourney's then upcoming video service. Press reports say Warner Bros. Discovery filed a similar suit later in 2025 and the cases were consolidated. As of July 2026 reporting, the case was still in discovery, with no ruling on fair use. Whatever the outcome, the studios have shown they treat AI images of their characters as infringing copies. A channel that monetises those images is the easiest target in that chain. The policy point is narrower than it is often reported. YouTube does not ban slideshows or AI. It removes monetisation from channels whose videos look templated and mass-produced. A video with movement, a story and an original song is a different case from a still-image slideshow, but YouTube decides at the channel level and does not publish a threshold. **How to calculate public domain in the EU and the US** EU rule. Directive 2006/116/EC sets the term at the author's life plus 70 years, counted from 1 January of the year after death. Romania applies the same rule in Law 8/1996 (article 28 for the term, article 34 for counting from 1 January). For films, the EU term ends 70 years after the death of the last survivor among the principal director, the screenwriter, the author of the dialogue and the composer of music written for the film. US rule. For works published before 1978 that kept their copyright, the term is 95 years from publication (17 U.S.C. section 304), and every term runs to the end of the calendar year. So works published in 1930 entered the US public domain on 1 January 2026, and works from 1931 enter it on 1 January 2027. Worked examples. Sherlock Holmes: Arthur Conan Doyle died in 1930, so his works have been free in the EU since 1 January 2001. In the US, the last ten stories stayed protected until The Case-Book of Sherlock Holmes expired, and all the Holmes works were free from 1 January 2023. In 2014 the Seventh Circuit ruled in Klinger v. Conan Doyle Estate that the elements in the already-free stories could be used without a licence. Peter Pan: J. M. Barrie died in 1937, so the EU term ended on 31 December 2007. In the US, the 1911 novel has been free since 1967 and the play since 2024, because its script was first published in 1928. Tintin: Hergé died in 1983, so Tintin stays protected in the EU until the end of 2053, even though the first Tintin entered the US public domain in 2025. Steamboat Willie: the 1928 Mickey entered the US public domain on 1 January 2024. Its EU status is harder; see "Where the reports were wrong". The 1931 Universal films. Universal's Dracula and Frankenstein, both from 1931, reach the end of the 95-year US term on 31 December 2026, provided their copyright was renewed. We did not check the renewal record. In the EU, the term of the films depends on when the director, the screenwriters and the composer died, so it does not end at the same time. Later Universal films stay protected longer in both systems. **The list: characters free in both the EU and the US** Gothic and classic monsters. Dracula (Bram Stoker, 1847-1912, novel published 1897) and Frankenstein's creature (Mary Shelley, 1797-1851, novel published 1818). Dracula carries a Romanian angle that no other country's channel has. The novel is free; the cape, slicked hair and accent of the 1931 film, and the flat head and neck bolts of Universal's creature, are later studio additions. Myth and epic. The Greek gods and heroes, Odysseus and the whole cast of the Odyssey (Circe, Poseidon, the Cyclops, the Sirens, Penelope), the Norse gods from the medieval Eddas, and Sun Wukong from Journey to the West, a classic Chinese novel. Homer and the Eddas are free; recent translations of them are not. Tales and children's classics. The Grimm brothers (Jacob died 1863, Wilhelm 1859), Hans Christian Andersen (died 1875), Pinocchio (Carlo Collodi, died 1890), Alice (Lewis Carroll, died 1898), Oz (L. Frank Baum, died 1919, first book 1900) and Peter Pan from Barrie's book, with the UK caveat explained below. Snow White, the Little Mermaid and Pinocchio are free as stories. Their Disney look is not. Legend and literature. Robin Hood and King Arthur, both medieval legends with no single author, Don Quixote (Cervantes, died 1616), the Thousand and One Nights (Aladdin, Ali Baba, Sinbad), Sherlock Holmes, and anonymous folklore. Romanian folklore fits here: Făt-Frumos, the zmei and Muma Pădurii are traditional figures. Folk tales are free; a modern writer's retelling and an illustrator's drawings of them are protected like any other work. Odysseus and the 2026 film. Christopher Nolan's The Odyssey, released by Universal on 17 July 2026 with Matt Damon as Odysseus, made the myth a search trend. Homer's poem is free; the film is not. That rules out its frames, costumes, poster style and music, and an Odysseus who looks like Matt Damon or any other actor. A realistic AI likeness of a real person also triggers YouTube's disclosure rule for content that makes a real person appear to say or do something. A descriptive title such as "Odysseus but in Tokyo" is fine in our reading, as long as nothing in the title, thumbnail or description suggests a link to the film. **Trap 1: the character is free, the famous design is not** When a book is free but a film built on it is not, the film's additions stay protected. In Warner Bros. v. X One X Productions (2011), the Eighth Circuit held that the copyrights in the 1939 Wizard of Oz film cover the increments of character expression that go beyond the books, and that new products evoking the film characters infringed even when built from public domain publicity images. The court pointed to Dorothy's distinctive blue dress, bow and ruby slippers. In Baum's 1900 book, Dorothy's shoes are silver. The same logic applies to Disney's Snow White, Pinocchio and Peter Pan and to Universal's monsters. The practical problem is that image and video models default to the famous version. A 2024 study presented at ICLR 2025 found that state-of-the-art image and video models can generate copyrighted characters without their names in the prompt, sometimes from only two generic keywords: "videogame, plumber" produced Nintendo's Mario. The authors found prompt rewriting alone insufficient and recommend adding negative prompts. In practice that means describing the original look from the source text (Stoker's Dracula is an old man with a long white moustache), asking for an original design, and naming in negative prompts the studio features you must avoid. Then check every frame by eye. Translations are a second version of the same trap. The English Pinocchio on Project Gutenberg is Carol Della Chiesa's translation, and she died in 1972. In the EU that translation is protected until the end of 2042, even though Collodi's Italian text is free. For narration or lyrics, work from the original-language text, a translation whose translator died more than 70 years ago, or your own translation. **Trap 2: a trade mark is not a copyright** Copyright expires; a trade mark lasts as long as it is renewed and used. On the day we checked, the EU trade mark register, through TMview, listed registered EU marks for TARZAN and TARZAN OF THE APES held by Edgar Rice Burroughs, Inc., including in class 9, which covers recordings and downloadable media, and class 41, entertainment services. It listed CONAN and CONAN THE CIMMERIAN held by Conan Properties International. Yet Burroughs died in 1950 and Robert E. Howard in 1936, so their stories are free in the EU, and Project Gutenberg hosts Tarzan of the Apes and Howard's Conan stories as US public domain texts. The register also lists SHERLOCK HOLMES marks held by Conan Doyle Estate Limited, in class 41 among others, and PETER PAN marks held both by Great Ormond Street Hospital Children's Charity and by Disney. MICKEY MOUSE is registered by Disney in classes 9 and 41. DRACULA appears in several registrations held by unrelated companies for goods such as food, spirits and games. No one holds the character, but some uses of the name are taken. What this means in practice, in our reading: telling a story about Tarzan or Sherlock Holmes in a video is a use of public domain material. Using the name as the brand of your channel, a product line or merchandise is trade mark territory. Keep protected names out of the channel name and logo, use them descriptively in titles, and never suggest an official link. A trade mark lawyer should check any name you plan to build a brand on. **Peter Pan in the UK: a royalty, not a ban** Peter Pan has a special status in UK law. Schedule 6 of the Copyright, Designs and Patents Act 1988 gives Great Ormond Street Hospital Children's Charity a right to a royalty for any public performance, commercial publication or communication to the public of the whole or a substantial part of Barrie's play Peter Pan, or of an adaptation of it. It is a right to be paid, not a right to stop the use; if the parties do not agree on the amount, the Copyright Tribunal sets it. Whether a YouTube video based on the 1911 novel rather than the play counts as an adaptation of the play is a question of interpretation we could not settle at a primary source. Combined with the PETER PAN trade marks, it makes Peter Pan the one entry on the list where we would check with a lawyer before a series reaches viewers in the UK. **SCP, Minecraft and the AI models themselves** SCP Foundation. Content from the SCP Wiki is published under Creative Commons Attribution-ShareAlike 3.0. Commercial use is allowed, including on YouTube. You must credit the article and its author's name or screen name, link to the source and release your video under the same CC BY-SA 3.0 licence, which means others may reuse it. The wiki's licensing guide names exceptions: the old SCP-173 image, based on Izumi Kato's sculpture "Untitled 2004", is not under the licence and should not be used commercially. Minecraft. Mojang's usage guidelines allow videos and streams of Minecraft gameplay to earn ad revenue, if the video is free to view and adds enough original content, such as your own commentary. The guidelines define sharing with others as commercial use even when it is free, and forbid anything that suggests the content is official. They say nothing about AI-generated Minecraft characters. Our reading is that an AI video of Steve or a Creeper is not gameplay footage and is not covered by the permission, and Mojang states that all permissions can be revoked at any time. Licences of the video models. The tool needs checking too, not only the character. The licence of Tencent's HunyuanVideo states that it does not apply in the European Union, the United Kingdom or South Korea, so a European operator has no licence to use it. Wan 2.2 from Wan-AI is published under Apache 2.0, which allows commercial use, with use restrictions listed on its model page. **Where the reports were wrong** 1. "Steamboat Willie is free only in the US." Our starting notes, like many news reports, said the 1928 Mickey is protected in the EU. The EU directive has a rule of the shorter term: for a work whose country of origin is outside the EU and whose author is not an EU national, the EU term ends when the term in the country of origin ends. The author of Duke University's Public Domain Day 2024 page reads this as making the 1928 Mickey public domain in the UK and much of the EU, with Germany an exception because of special rules. Romania's law refers foreign works to the treaties Romania is party to (article 202), and we did not find an explicit transposition of the rule there. The copyright question is less settled than the notes said. The practical answer does not change: Disney holds registered EU marks for MICKEY MOUSE in classes 9 and 41. 2. "A slideshow means near-certain demonetisation." The notes said the policy names static slideshows textually. It names image slideshows "with minimal or no narrative, commentary, or educational value", and YouTube applies the policy to the channel as a whole. The format is high-risk, not automatically excluded. 3. "Sherlock Holmes is fully free." The copyright dates in the notes were right: free in the EU since 2001 and in the US since 2023. The notes missed that Conan Doyle Estate Limited holds registered EU marks for SHERLOCK HOLMES, including in class 41. Trap 2 applies to Holmes as well, not only to Tarzan and Conan. 4. "Minecraft allows monetisation." The permission covers gameplay videos with original content and ad revenue. It does not mention AI-generated images of Minecraft characters, so it should not be read as a licence for them. 5. Peter Pan listed as simply free. It is free in the EU and the US. The UK royalty right and the PETER PAN marks make it the exception on the list. **Method** We started from our own working notes on relaunching an AI character channel on public domain material, which listed candidate characters and three traps. On 19 October 2026 we checked each point at a primary source: YouTube's monetisation policy page, the Disney and Universal complaint against Midjourney, Directive 2006/116/EC on EUR-Lex, 17 U.S.C. section 304, Romania's Law 8/1996 on the official legislation portal, Schedule 6 of the UK Copyright, Designs and Patents Act 1988, Duke University's Public Domain Day pages for 2023, 2024 and 2025 (the 2023 page for the Sherlock Holmes dates and the Klinger case), the Eighth Circuit's 2011 opinion in Warner Bros. v. X One X, the ICLR 2025 study on copyrighted characters, Mojang's usage guidelines, the SCP Wiki licensing guide, and the HunyuanVideo and Wan 2.2 licences. Author life dates and the Oz silver shoes come from Project Gutenberg records and text. Trade marks were checked as EU trade marks on TMview on the same day. We could not verify, and left out: the live status of the US trade mark registrations for Tarzan and Conan (the US register refused our requests), the renewal records of the 1931 Universal films, and the outcome of the Midjourney case. The case status rests on press reports from July 2026. We searched only EU trade marks; national marks in individual member states were not checked. **Limitations** This is a summary of rules and records, not legal advice, and copyright for characters turns on facts. The rule of the shorter term and the scope of the Peter Pan royalty are matters of interpretation. Trade mark registers change and our snapshot is from one day. YouTube's monetisation decisions are made by reviewers at the channel level and are not published as thresholds, so no list of characters can guarantee monetisation. Public domain status also says nothing about defamation, personality rights of real people, or the separate rights in music used in a video. ### How do you get a D-U-N-S number for a Romanian SRL, and why do Apple and Google ask for one? A D-U-N-S number is free, and Apple and Google both require one before a company can open an organisation developer account. In Romania, Dun & Bradstreet is represented by its partner ICAP CRIF, but a company can also request the number through Apple's lookup tool, where Apple tells you to allow up to 5 business days. The details you enter have to match the company's registered legal name and headquarters address, or verification fails later. **The short answer** A D-U-N-S number is a nine-digit company identifier issued by Dun & Bradstreet (D&B). Apple requires one to enrol a company in the Apple Developer Program, and Google requires one to create an organisation developer account on Google Play. Both companies and D&B state that the number itself is free. For a Romanian SRL there are two free doors. The first is D&B's Romanian partner, ICAP CRIF, through its registration form. The second is Apple's D-U-N-S lookup tool, which needs only a free Apple Account and sends the request to D&B. Apple says to allow up to 5 business days; D&B's general figure is up to 30 business days. Whatever the route, enter the legal name and headquarters address exactly as they appear in the trade register, because Google and Apple compare them later. This article is for founders of small Romanian companies who need the number for an app store. We checked each rule in Apple, Google, D&B and Microsoft documentation on 19 October 2026. **Why Apple, Google and others ask for it** Apple's enrolment page says an organisation, except government entities, must have a D-U-N-S number so Apple can verify its identity, legal entity status and address. The organisation must be a legal entity that can sign contracts; Apple does not accept DBAs, trade names or branches, and the legal name becomes the seller name on the App Store. Membership costs 99 USD a year, with prices shown in local currency. Google Play says the requirement is mandatory: You will not be able to create a developer account for an organization without one, and Google will not grant exemptions or extensions. An alternative verification exists only for organisations in regions D&B does not support. Romania is covered by a D&B partner, so that route does not apply. Microsoft is softer. For the Microsoft Store, under the EU Digital Services Act, a company account classed as a trader must give either a D-U-N-S number or a business registration document. Microsoft recommends the number as the faster option and notes that uploaded documents can take significantly longer to review. One use people still repeat no longer applies. US federal registration in SAM.gov stopped using D-U-N-S numbers on 4 April 2022 and switched to its own Unique Entity ID. **Who issues it in Romania** D&B does not run its own office in Romania. On D&B's page for Google developers, the country list places Romania among the worldwide network partners and links it to ICAP B2B, a site operated by ICAP CRIF S.R.L., based in Voluntari, Ilfov. ICAP CRIF's homepage advertises an exclusive partnership with Dun & Bradstreet, and its site lists a dedicated contact for D-U-N-S questions. The partner's registration form asks for the reason for the request, with Apple/Google listed as an option, then the company name in English, the fiscal code, the headquarters address, city, municipality, phone and email, plus the details of the person sending it. We did not find a published processing time for this form. **The free route through Apple** Apple's lookup tool sits behind an Apple Account sign-in; joining the paid developer programme is not required to use it. Apple asks for the legal entity name, the headquarters address, the mailing address and your work contact details. If the company is not found, Apple offers to submit its information to D&B for a free number. Apple's own wording on timing is precise: allow up to 5 business days to receive the number from D&B, and expediting the creation process will not shorten that waiting period. If it takes longer than two weeks, Apple tells you to email D&B. After the number arrives, Apple needs up to 2 more business days to receive the data before you can enrol. A D&B representative may contact you, so keep the registration documents to hand. The number is not tied to Apple. It is a D&B identifier, and the same number is what Google Play asks for. **How long it really takes, and what paid options exist** The official figures differ by source. D&B says normal processing can take up to 30 business days. Google's Play help says the process can take up to 30 days and to plan ahead. Apple says up to 5 business days through its tool, with a two-week escalation point. D&B sells an expedited service called DUNSFile for 229 USD with delivery in up to 8 business days, but the page tells companies outside the US to contact the D&B office in their country. It is not a product a Romanian SRL can buy. Google notes that some markets may promote a paid expedited service, while all markets are obliged to offer the free one. In our own request, sent through Apple's tool in September 2026, D&B's resolution email arrived three calendar days later. It is one data point. The resolution said the number already existed and was verified through the national registry, so D&B confirmed an existing record rather than creating one; a new company with no record could take longer. **What has to match, and the usual traps** Google states that for an organisation the legal name and address in the Google payments profile must match those in the D&B profile. If they do not, Play Console shows We couldn't match the D-U-N-S number and the address that you provided. A correction has to be made at D&B first, and Google says it can take up to 5 business days after D&B finishes for the update to reach Google. Name spelling. The D&B record we received used the legal name exactly as the national registry spells it, with the Romanian diacritics and with S.R.L. written with full stops. A request typed as a lowercase name without full stops is a different string. Our reading is that the safest input is a copy of the name and address from the trade register record or the company's registration certificate, not from memory or from an invoice template. Headquarters, not a work point. Apple asks for the headquarters address and does not accept branches. Use the registered office (sediul social) as recorded in the trade register, even if the team works from a different address. A very new company. Our record was matched against the national registry, which suggests a company needs to be registered there before D&B can confirm it. We did not find a document stating how soon a newly registered Romanian company appears in D&B data. Payments profile type. Google says the account type and country of a payments profile cannot be edited; changing them means creating a new payments profile. An organisation Play account also cannot be converted back to an individual one. Set up the payments profile as a business from the start. **How to check whether you already have a number** Start with Apple's lookup tool, since it is free and already part of the request flow. Be careful with a negative answer. In our case the tool said the organisation was not found before the request, and it still said so the day after D&B had confirmed that the number existed. A not-found result is not proof that no number exists. The reliable record is D&B's resolution email, which states the number. If the company may already have been registered for a bank, a tender or a foreign customer, it is also worth asking the D&B partner whether a record exists before creating a second request with slightly different details. Google charges a one-time 25 USD registration fee at step 3 of sign-up, before you choose the account type. If you are opening an organisation account, it makes sense to have the D-U-N-S number before paying. **Where the reports were wrong** 1. A guaranteed Apple corridor. The AI report said Apple had negotiated an agreement that forces D&B to issue numbers within 5 business days. Apple's page says to allow up to 5 business days, adds that expediting does not shorten the wait, and tells you what to do after two weeks. That is guidance, not a guarantee. 2. DUNSFile in 5 days. The report cited an affiliate blog for a 5-day DUNSFile. D&B's own page says up to 8 business days, and that companies outside the US should contact their local D&B office. 3. A 14-day wait before using the number in Play. The report prescribed a quarantine of at least 14 days. We found no source for it. Google's documented figure is up to 5 business days for a D&B correction to reach Google. 4. A new developer account for a wrong payments profile. The report said a profile created as individual forces a new developer account and a new fee. Google's page says a new payments profile is needed to change the account type, and describes a path to convert an individual developer account to an organisation one. 5. Claims about the partner's motives. The report said the partner systematically delays free requests to the 30-day limit and routes Romanian visitors by IP address. We found no primary evidence for either and left both out. 6. Google's Romanian links. Google's help page links Romania to two partner forms on an old survey-platform address. Both returned Survey not found when we checked. The partner's working form is linked from its own site. **Method** We started from an AI deep-research report on D-U-N-S numbers for Romanian companies and from our own notes on requesting a number in September 2026. On 19 October 2026 we checked each rule at the source: Apple's D-U-N-S help page and enrolment page, five Google Play help pages, D&B's pages on getting a number, on DUNSFile and for Google developers, the partner's homepage, its D-U-N-S page and its registration form, Microsoft's company verification page and the GSA notice on SAM.gov. We also tested whether the Google and partner form links resolve. We could not verify, and removed: the partner's processing time for free requests, the claim that a paid partner package delivers in 1 to 3 business days, community reports of 4 to 5 weeks of waiting, and any processing-time figure for the D&B lookup portal. **Limitations** Our timing evidence is a single request, for a company that already had a D&B record. It does not show how long a new record takes. The paid quote is what one partner offered us by email at one date, not a public tariff. Apple, Google and D&B change their pages and forms; the links and figures here were correct on the day we checked. Nothing here is legal advice: where we say what to enter, that is our reading of the matching rules, not a statement from Apple, Google or D&B. ### Google Play developer account in 2026: personal or organization, what verification asks for, and what the new rules change A Google Play developer account costs a one-time US$25 and comes in two types. A personal account needs a government ID but must run a closed test with at least 12 testers for 14 consecutive days before production; an organization account skips that test but needs a D-U-N-S number, organization documents and a verified website, and shows its address and phone number on Google Play. A personal account can now be switched to an organization, not the reverse. **The short answer** Opening a Google Play Console account costs a one-time US$25. You then pick a personal or an organization account. A personal account verifies one person with a government ID. If it was created after 13 November 2023, it must run a closed test with at least 12 testers opted in for 14 consecutive days before it can apply for production access. An organization account needs a D-U-N-S number, an official organization document, a verified website and a developer phone number shown on Google Play, but Google's help pages list the extra testing requirement only for personal accounts. Separately, Android developer verification, which also covers apps installed outside Google Play, started on 30 September 2026 in four countries only, and Google plans to extend it globally in 2027. This article is for small companies and solo developers deciding which account to open. We checked each rule in Google's own help pages, on the Android developer site and in the text of the EU Digital Services Act, on 19 October 2026. **Personal or organization: what each one asks for** Google's page on required information lists, for a personal account, a developer name, legal name and address, a contact email and phone number, and a developer email address. For an organization it adds a D-U-N-S number, the organization's name, address and phone number, an organization website and a contact person. Contact email, contact phone and developer email are verified with a one-time code. The choice is not only about paperwork. Google's Play Console Requirements say that developers offering financial products and services, health apps, apps using the VpnService class and government apps must register as an organization. A company that might ever ship one of those should not start on a personal account. A D-U-N-S number is a nine-digit business identifier assigned by Dun & Bradstreet. Google says it cannot create an organization account without one and will not grant exemptions or extensions, with one exception for known government bodies. Google's 2023 announcement says the number can be requested for free and that the process can take up to 30 days, so it should be requested before signing up. **Verification: identity, organization, website and phone** Google's identity verification page says a personal account submits an official government identity document. An organization submits its D-U-N-S number, an official government identity document and an official organization document. All documents must exactly match the information in the linked Google payments profile. That payments profile is attached when the account is created, and its country, account type and D-U-N-S number cannot be edited later: changing any of them requires a new profile. For organizations, verifying the organization website is a prerequisite; for other developers it is optional. Organizations must also provide and verify a developer phone number that is shown on Google Play. The same page states that apps cannot be submitted for review until the account is verified. In our own organization sign-up, the D-U-N-S record held an older registered address. Matching the D-U-N-S number against the current address failed, the form warned that the number of attempts is limited, and the match succeeded only with the address on the D&B record. Since documents must match the payments profile exactly, an outdated D&B record is worth correcting before you start, not after. **The closed test for new personal accounts** Google's page on testing requirements applies to personal accounts created after 13 November 2023. Before applying for production access, the app must run a closed test with at least 12 testers who have been opted in continuously for at least 14 days. The days must be consecutive: a tester who opts out and back in restarts the count. Google's sign-up page adds that new personal accounts must also verify access to an Android device through the Play Console mobile app. After the test, the developer answers a production-access application in three parts: the closed test, the app, and production readiness. Google says the review usually takes seven days or less. Counted together, the test and the review mean about three weeks between a finished build and a public listing, for every new app on such an account. Google's sign-up page describes these extra testing and device requirements as applying to personal accounts. We found no Google page that applies them to organization accounts. **Switching account type later** Google's page on updating developer identity details now describes converting a personal account into an organization account. The account must be fully verified first, the organization website must be added and verified, and then the developer chooses Change account type and links an organization payments profile, which requires a D-U-N-S number. Google asks developers to wait at least 72 hours after the switch before submitting new apps. The reverse is not possible. Google states that an organization account cannot be changed back to an individual account; the developer would have to open a new individual account and transfer the apps to it. Separately, the account details page shows the account type as read-only, which means it cannot be edited in place, only changed through that conversion flow. **Android developer verification and sideloading: the 2026 calendar** Android developer verification ties every app installed on certified Android devices to a verified developer, including apps distributed outside Google Play. Google's developer site gives this calendar: in August 2026, the developer APIs, free limited distribution accounts and an advanced flow for power users launched. On 30 September 2026, protections began for users installing apps from participating stores in Brazil, Indonesia, Singapore and Thailand, on certified devices running Android 7 and later. Google says verification will be extended globally in 2027. Google's June 2026 blog post lists seven stores taking part at launch, Google Play among them. Its FAQ says that apps distributed through other stores, or sideloaded directly, are not yet affected, and that installs through adb do not require verification. The advanced flow is a one-time setup with a one-day wait, after which a user can allow installs from unverified developers for 7 days or indefinitely. For Play developers the burden is small. Google says 99% of Play apps were registered automatically, new package names are registered when an app is created in Play Console, and an identity already verified for Play meets the requirement. Apps on Play that were still not registered by 30 September 2026 were to be removed. A developer who distributes only outside Play uses the new Android Developer Console; its full distribution account carries a US$25 fee, and limited distribution accounts can share an app with up to 20 devices without an ID or a fee. **What a small EU company publishes on its listing** Google's account information page says that for an organization, Google Play shows the legal name, legal address, developer email address and developer phone number. For a personal account it shows the legal name, the country and the developer email, but any account that monetizes through paid apps or in-app purchases must show its full address, taken from the payments profile. For a solo developer selling from home, that means the home address on every listing. The EU Digital Services Act sets the legal background. Article 30 requires online marketplaces to collect from traders a name, address, telephone number and email address, an ID copy, payment details, a trade register number where one exists, and a self-certification. Article 30(7) requires the platform to make the name, contact details, trade register number and self-certification available to users where the product is presented. The Act defines a trader as anyone acting for purposes of their trade, business or profession. On a Google Play listing we opened from Germany, the About the developer block showed the company name, a support email, a full street address and a phone number. We did not find a Google help page that uses the term trader status for Play, so how Google maps each account to the DSA categories is our interpretation. In practice, a registered company should expect its registered office and a phone line to be public, and should choose them with that in mind. **What organic Play traffic looks like for a new app** We found one public, regularly updated dataset: AppBrain's statistics on Google Play, updated on 18 October 2026. It counts 2,031,834 apps. Of them, 52.1% have fewer than 1,000 downloads and only 3.3% have more than a million. AppBrain also follows weekly cohorts of new apps. For apps 23 weeks after launch, its chart shows 35.1% with more than 500 downloads, 14.0% with more than 5,000 and 4.2% with more than 50,000. These figures include apps that buy advertising, so they overstate what an app with no marketing at all can expect. We found no public data that separates organic installs from paid ones. **Where the reports were wrong** 1. Switching account type. Our own earlier notes said a personal account could not be converted and that apps would have to be transferred to a new organization account. Google's current help page describes the personal-to-organization conversion directly. The deep-research report had this right; only organization to personal is blocked. 2. The sideloading calendar. The report said the new verification requirements took effect in August 2026 and found no geographic phasing, calling it a global switch. Google's pages say August 2026 was the launch of tools and accounts, enforcement began on 30 September 2026 in four countries and only for participating stores, sideloaded apps are not yet affected, and the global extension is planned for 2027. 3. A purge on 30 September. The report presented 30 September 2026 as a deadline that would remove unregistered apps from Play. The removal rule exists, but Google says 99% of Play apps were registered automatically and new apps are registered when created, so for most Play developers it required no action. 4. D-U-N-S timing. The report quoted a community forum post saying D&B takes 30 business days. Google's own announcement says up to 30 days. Neither is a guarantee, and we did not find the current D&B timetable on a primary page, so plan for weeks, not days. 5. Organizations and the closed test. Our earlier notes said Google never states that organizations are exempt from the 12-tester test. Google's sign-up page now describes the extra testing requirement as applying to personal accounts. It is still phrased as a personal-account requirement, not as an explicit organization exemption. **Method** We started from an AI deep-research report on Play Console verification and from our own notes made while opening an organization account. Both were treated as leads, not sources. We removed internal recommendations and account details and checked every rule on 19 October 2026 in Play Console Help pages on sign-up, required information, identity verification, account type changes, account information, testing requirements, the Play Console Requirements and package registration; on the Android developer verification pages, FAQ and June 2026 blog post; in Google's July 2023 announcement on D-U-N-S; in the EUR-Lex text of the Digital Services Act; on a live Play listing; and in AppBrain's download statistics, which we read from the chart data on the page. We dropped what we could not confirm at a primary source: the history of the tester requirement before its current values, the price and speed of expedited D-U-N-S issuance, the date Google began collecting DSA trader declarations, and organic install figures for specific niches from our own earlier scans. **Limitations** Google changes Play Console help pages without dated changelogs, and several of the pages we read carry no last-updated date, so each rule should be checked again before acting on it. The DSA section combines the text of the regulation, Google's statements on displayed information and one listing we inspected; it is not legal advice. The organic-traffic figures come from a third-party dataset that mixes paid and unpaid apps and reports downloads in brackets, not exact counts. ### What sells on the Atlassian Marketplace in 2026, and how do you read the demand signals for a Jira or Confluence app? Installs are concentrated in integrations, admin toolkits, time tracking, test management and diagrams: the top 50 apps hold more than half of all installs, and the median app has single-digit installs on Jira. Small Forge apps still get in, mostly in reporting gadgets, admin utilities and diagram-as-code, while Atlassian charges no revenue share on Forge revenue up to $1 million per partner. The end of Data Center, on 28 March 2029, is the main engine of new cloud demand. **The short answer** On 19 October 2026 the Atlassian Marketplace listed 4,810 Jira Cloud apps and 1,869 Confluence Cloud apps. Installs are concentrated: the top 50 apps hold 52.9% of all Jira installs and 56.6% of Confluence installs, and the median app has 6 installs on Jira and 16 on Confluence. What sells in volume is integrations with large tools (source code, design, monitoring), admin and workflow toolkits, time tracking, test management, diagrams and tables. Small apps still get in, but rarely and in specific places. Of the 1,631 Jira listings whose first cloud version is dated 2026, 47 had reached 100 installs; on Confluence it was 11 of 466. The economics favour Forge: since 1 January 2026 Atlassian takes no revenue share on eligible Forge revenue until a partner reaches $1 million in lifetime Forge revenue. The structural driver of demand is the end of Server and the scheduled end of Data Center, which pushes customers, and their apps, to Cloud. This article is for people deciding whether to build for Jira or Confluence. Every platform rule below was checked in Atlassian's own documentation or announcements, and every market figure comes from the public Marketplace API, read on 19 October 2026. **What the Marketplace looks like in October 2026** We read every cloud listing for Jira and Confluence from the public Marketplace API (REST v2). For Jira, 228 apps have at least 1,000 installs and 2,643 have fewer than 10. For Confluence, 104 apps have at least 1,000 installs. 2,737 Jira apps (57%) and 990 Confluence apps (53%) have no review at all. Concentration by vendor is as strong as by app. The Jira listings come from 2,270 vendors, and the top 10 vendors hold 47.6% of Jira installs. Atlassian itself is the largest, at 11.9%, followed by Appfire with 9.0% across 56 listings and Tempo with 7.8%. On Confluence, 742 vendors share the installs and the top 10 hold 50.0%; the largest are the draw.io vendor with 11.7% and Appfire with 10.7% across 42 listings. Many listings are no longer active products. 1,362 Jira listings and 467 Confluence listings had not shipped a new cloud version in the 12 months before our reading. For a newcomer this cuts both ways: part of the shelf is abandoned, and part of the competition a scan counts does not really compete. **What sells: the categories and the leaders** The largest Jira apps by installs are integrations owned by other platforms: GitHub for Atlassian (137,701 installs), Figma for Jira (48,422) and GitLab for Jira Cloud (32,244). Behind them come the classic paid categories: scripting and admin toolkits such as ScriptRunner (34,580), checklists, timesheets from Tempo (26,396), test management such as Xray (25,238) and Zephyr, and workflow extensions such as JMWE (16,450). On Confluence the leaders are diagrams and structured content. draw.io has 60,954 installs and a rating of 4.81 from 1,207 reviews; Lucidchart, Miro and Gliffy follow. Table Filter, Charts & Spreadsheets (14,954) and Scroll PDF Exporter (8,741) show the other durable demand: turning pages into data and documents. Ratings and installs move independently. GitHub for Atlassian rates 3.34, GitLab for Jira Cloud 2.75 and Sentry for Jira 2.47, and all three sit in the top tier by installs. An official connector is installed because the other product is used, not because the connector is liked, so a low rating on a connector is not an opening for a third party. Category shares show where a newcomer meets a wall. In the Jira Source code category one app holds 74% of installs and in Deployments 83%; on Confluence, draw.io holds 82% of Blueprints. The broad Jira Project management category is the opposite: 1,160 apps, and the leader holds 4%. **How to read the demand signals** Installs. Each listing in the public API carries a totalInstalls figure. We found no Atlassian page that defines it. Two things we could observe: the API returns the same number whatever hosting filter is applied, so for apps that also sold a Data Center version it cannot be split by hosting; and the number moves down as well as up. ScriptRunner for Jira read 34,580 and, a few minutes later the same morning, 34,568. It behaves like a current count of installations, not a lifetime total. It counts free tiers and trials as well, so it is not a count of paying customers. Reviews. More than half of all listings have none, so the absence of reviews says little about a small app. For a leader, the text of the low-rated reviews is useful as a specification of what is missing; the count of complaints is not a measure of willingness to pay, as we found in earlier work on review data. Feature requests. Atlassian's public tracker, often called JAC, lists feature requests with vote counts. We measured separately how votes on such requests convert into installs of the apps that answer them, and the conversion was poor; the status of a request carried more signal than its votes. We do not repeat that measurement here. The practical rule stays the same: use the tracker for hypotheses and use Marketplace installs as the check before building. The API itself. In November 2024 Atlassian staff listed GET /rest/2/addons among the Marketplace V2 APIs to be deprecated, with removal planned for May 2025. On 19 October 2026 the endpoint still answered. Anyone building a scanner on it should expect it to change without much notice. **Where small apps still get in** To see whether new apps reach anyone, we used the date of each listing's earliest public cloud version. That date is unreliable for older apps: hundreds of long-established listings show an earliest version in early 2025, which suggests the version record was reset then. So we only treat apps whose first cloud version is dated 2026 as a new cohort, and even that cohort may include some relisted apps. The 2026 cohort is large and mostly empty. On Jira, 1,631 listings first appeared in 2026; 531 have no installs and 1,315 have five or fewer. 47 reached 100 installs and 4 reached 1,000. The top three are free AI coding agents published by Atlassian, Cursor and GitHub, with 9,468, 3,690 and 3,519 installs. On Confluence, 466 listings appeared in 2026, 366 have five or fewer installs, 11 reached 100 and none reached 1,000. The new apps that did reach 100 cluster in a few kinds of work. On Jira: agile metrics and reporting gadgets, planning poker and retrospectives, admin tools such as user and licence export, file and dynamic custom fields, and connectors to CRM and service desk products. Several of the 47 come from vendors that publish families of related apps, with similar install counts across the siblings. On Confluence, four of the 11 render diagrams written as code: three Mermaid apps and one PlantUML app. Diagram-as-code shows why such niches stay open. The most installed Mermaid viewer on Confluence, published by Atlassian Labs, has 7,649 installs and a rating of 2.99 from 26 reviews; the next one has 6,252 installs and 4.02. Demand is proven, no listing is clearly liked, and the leader is a free lab project rather than a vendor that defends the niche. **The Forge economics: revenue share and billing** Atlassian announced the current revenue share model on 5 May 2025 and moved its dates on 3 November 2025. As of 19 October 2026, Atlassian's partner FAQ states payouts to partners of 83% for Forge apps and 75% for Connect apps from 1 October 2026, and 75% for Data Center apps. In Atlassian's own terms the commission is 17% on Forge and 25% on Connect. The increases were first scheduled for 1 January and 1 July 2026 and were deferred by three months. On top of that sits the Forge incentive. Since 1 January 2026 partners pay 0% revenue share on eligible Forge earnings until they reach $1 million in lifetime Forge revenue, counted per partner company across all its Forge apps, subsidiaries included. Apps carrying the Runs on Atlassian badge received it earlier, from 5 October 2025. To count as Forge, an app must have no Connect modules, use OAuth authentication and use Forge UI. Atlassian calls the incentive temporary and says partners will get at least six months' notice before standard rates change. Atlassian also runs the money. Cloud apps must be paid via Atlassian; a customer enters a 30-day free trial that rolls automatically into a paid subscription, and billing continues until the customer cancels. For a small vendor this removes the billing, licence server and invoicing work entirely. It does not create customers: on 19 October 2026, 2,976 Jira listings were paid via Atlassian, 1,519 were free, and most of the paid ones have very few installs. **Trust badges: Runs on Atlassian, Cloud Fortified and what replaces it** Runs on Atlassian is a badge Atlassian applies automatically to Forge apps that meet three requirements: they use only Atlassian-hosted compute and storage, they support data residency matching the host product, and customers can control external data egress through admin settings. Partners do not apply for it. An app that sends data to an outside service, for example an external AI model, loses it. Cloud Fortified still appears on listings: the API returned 692 Jira and 486 Confluence cloud apps with approved Cloud Fortified status. But Atlassian's documentation says the programme no longer accepts new submissions and will be phased out by the end of calendar year 2026. Its successor, Atlassian Enterprise Certified, is open to Forge cloud apps and asks for an annual penetration test, a public status page, SOC 2 Type II or ISO 27001 compliance, a public accessibility report and an AI usage policy where AI is used. Read together, the badges split the market. Runs on Atlassian is cheap to earn for a simple Forge app and signals that data stays inside Atlassian. The enterprise certification is the expensive tier, and a solo vendor is unlikely to meet its compliance requirements quickly. **The demand engine: end of Server, end of Data Center, end of Connect** Atlassian ended support for its Server products on 15 February 2024, including Marketplace apps for Server. In September 2025 it announced the same path for Data Center: no new Data Center app submissions from 16 December 2025, no new Data Center subscriptions or Marketplace apps for new customers from 30 March 2026, a last purchase date for existing customers of 30 March 2028, and expiry of all Data Center licences, which then become read-only, on 28 March 2029. Bitbucket Data Center apps are exempt. For app vendors this is the main source of new cloud demand in the next years: every Data Center customer that moves has to replace or migrate its apps. Atlassian describes its customer base as more than 300,000 customers in that announcement and states $6B in lifetime Marketplace sales on its developer site. The same transition closes doors on the supply side. New Connect apps could no longer be listed from 17 September 2025, apps with a Connect descriptor could no longer be updated from March 2026, and Connect end of support, first planned for December 2026, was moved to 31 January 2027. Atlassian's documentation also gives 2 March 2026 as the start of enforcement for points-based API rate limits on Jira and Confluence Cloud apps, by default 65,000 points per hour per app. A new entrant builds on Forge from day one; many incumbents are still paying for the migration. **Where the reports were wrong** 1. Who shares the API quota. One AI deep-research report said the 65,000-point hourly quota is shared across all tenants and all apps installed on a Jira site, and proposed a quota monitor for site administrators. Atlassian's documentation describes the default as one quota per app, shared across all of that app's tenants, with a per-tenant pool assigned only after Atlassian review; API token traffic is excluded. The other report described the default correctly. 2. A badge that skips security review. Both reports said Runs on Atlassian lets a solo developer bypass enterprise security reviews entirely. Atlassian's page on the badge says nothing about security reviews or procurement, and notes that egress controls do not prevent misuse of access granted at installation. Atlassian's own enterprise certification still asks for SOC 2 Type II or ISO 27001. 3. Marketplace data shows only legacy apps. A report argued that any app with enough installs to register is by definition a legacy app, so the API is blind to new entrants. In today's data 47 Jira listings first published in 2026 already have at least 100 installs. The API sees new apps; it mostly shows that they get very few installs. 4. Votes as validated demand. Both reports treated feature-request votes, and declined requests in particular, as measured demand. Our separate measurement found that votes convert poorly into installs, and one of the reports' top candidates rested on a request that Atlassian had already resolved natively. 5. Dates that moved. Secondary sources still quote revenue share increases on 1 January and 1 July 2026, and Connect end of support in December 2026. Both were moved, to 1 April and 1 October 2026, and to 31 January 2027. Guides that tell new vendors to aim for Cloud Fortified are also out of date, since the programme no longer takes new submissions. **Method** On 19 October 2026 we read every Jira Cloud and Confluence Cloud listing from the public Marketplace API (REST v2): name, vendor, categories, totalInstalls, review count and average rating. For each listing we also read its latest and earliest public cloud version, which gave the payment model, the date of the last release and a first-release date. We counted Cloud Fortified apps with the API's own status filter. Concentration figures are sums of installs, which count installations, not distinct customers. For the rules we read Atlassian's sources directly: the revenue share announcements of May, October and November 2025, the partner pricing FAQ, the documentation on selling on the Marketplace, Runs on Atlassian and Enterprise Certified, the Server end of support FAQ, the Data Center announcement for Marketplace apps, the Connect end of support announcements and the Jira Cloud rate-limiting page. Our starting points were two AI deep-research reports and our own August scan; none of their figures is used here without a fresh check. We could not verify, and dropped: a claim that Marketplace bug bounty costs can reach a fixed daily amount, a limit of 128 KB for structured values in Forge storage, the number of apps carrying the Runs on Atlassian badge, which the API we used does not expose as a filter, and the revenue of any app. **Limitations** Installs are not sales. The public number includes free tiers and trials, its definition is not documented, and for apps that also sold Data Center versions it cannot be split by hosting. The first-release dates rest on a version record that appears to have been reset in early 2025 for many listings, so the 2026 cohort is an approximation. Category labels come from the older taxonomy still exposed by the API, and many new listings carry none. All figures are a single reading on one day; install counts change daily. The endpoint we used was announced for deprecation in 2024 and may stop working. ### Why do most new Shopify apps fail, and where is there still real demand in 2026? Most new Shopify apps never get a single review: in our random sample of 400 listings on 19 October 2026, 62% had none, and 79% of the apps launched in 2026 had none. The shelf is filling faster than merchants arrive, and the reviews that do arrive go to a few apps per niche. The demand that is left is easiest to find where a platform change reset a category, and it can be tested from public data before any code is written. **The short answer** A new Shopify app usually fails before anyone judges the product, because nobody sees it. On 19 October 2026 we drew a random sample of 400 apps from the App Store's own sitemap and opened each listing. 62% had zero reviews, 90% had fewer than ten, and only 4% had fifty or more. Among apps launched in 2026 the share with zero reviews was 79%. Three things explain most of it. The number of listings is growing quickly, so each new app competes with more neighbours for the same searches. The reviews that exist sit in a small number of apps. And the documented helpers, such as Built for Shopify, reward traction an app already has rather than creating it. Demand is still visible in places, mostly in categories that Shopify itself reset by retiring an old way of customising stores. This article is about choosing such a niche and testing it before building. How App Store search ranks apps, and the 2026 deadlines themselves, are covered in separate articles on this site. **How many apps never get a review** A review is the only public trace a merchant leaves on a listing, so an app with zero reviews has shown no evidence of a single engaged customer. It is a weak signal for any one app, since many merchants never review, but across thousands of apps it describes the market. In our sample the median app had 0 reviews, three apps in four had 2 or fewer, and nine in ten had 11 or fewer. Age explains part of it, but not all. Among sampled apps launched in 2024 or earlier, 35% still had zero reviews after at least 21 months on the store. Among apps launched in 2026, 79% had zero and 6% had reached five reviews, which is the review threshold Shopify sets for Built for Shopify. For an app launched this year, staying at zero is the normal outcome, not bad luck. **The shelf is filling faster than buyers arrive** The App Store's English sitemap listed 28,639 apps on 19 October 2026. When we counted the same sitemap on 14 July 2026 it listed 23,319. In our sample, 48% of listed apps had a launch date in 2026 and 19% had launched since mid-July. Read together, roughly one listing in five on the store today is about three months old or younger. We did not find a Shopify statement on why launches accelerated, and we do not attribute it to any cause. The consequence for a new app is mechanical either way: more listings answer the same search terms, and a merchant still installs one app per job. A crowded shelf does not prove demand fell, but it does mean the same demand is split across more entrants. **Where the reviews go** On 5 August 2026 we collected the review count of 22,901 apps from App Store search result cards. Together they held about 839,000 reviews. The top 1% of apps by review count held 59% of them, and the top 100 apps held 44%. The median app had zero. Concentration also holds inside a single job. In the first three pages of App Store search for product reviews, after removing unrelated results, 72 apps held 90,197 reviews and Judge.me Product Reviews alone held 48,736, or 54%. A niche like this has proven demand and a buyer who already chose. A new entrant there needs a reason for a merchant to switch, not just a listing. **What it costs to be seen** Listing on the App Store costs little in fees. Shopify charges a one-time registration fee of $19 per Partner account, and developers on the standard terms keep 100% of their first $1,000,000 of lifetime gross app revenue counted from 1 January 2025, then 85% above it. The cost that decides the outcome is distribution. The paid route inside the store is App Store ads. Shopify's documentation says all ads are cost-per-click and the cost is set by a first-price auction, so a winning advertiser pays what it bid rather than the runner-up's price. For search ads relevance also counts: in Shopify's own example a highly relevant app takes position one while paying $0.50, and the highest bidder is not shown because its relevance is low. Shopify suggests a default bid of $1 when nobody else bids on a keyword. Shopify does not publish typical cost-per-click by category, and we could not confirm any figure for it. A paid click is not a customer. Each click must still turn into an install, the install into an active use, and the use into a paid plan. Without an estimate of those steps, an ad budget measures only how many people saw the listing. **Built for Shopify: a reward for traction, not a route to it** Built for Shopify is a status Shopify awards to apps that meet its quality bar. Shopify lists its benefits: a badge and highlight, a search filter, a search ranking boost, priority app review, plan-based targeting for App Store ads, and eligibility for promotion on the homepage, category pages, story pages and in the Shopify admin. Apps are reviewed annually and get 60 days to fix a lapse. The prerequisites include at least 50 net installs from active shops on paid plans and at least five reviews. That is the catch for a new app: the status amplifies traction and does not create it. In our sample 4% of all apps carried the badge, none of them with fewer than five reviews, and among apps that had reached five reviews the share was 23%. The badge is common among apps that already have customers and absent among those that do not. **Why apps are sent back at review** Shopify's review documentation lists the common problems that send an app back: pricing information that is not accurate, plans a merchant cannot upgrade or downgrade, billing outside Shopify's billing system, an install that does not go straight to the OAuth grant screen or ends in a fatal error, an app that does not reinstall properly, an app that switches between embedded and non-embedded versions, a broken interface or web errors, missing testing instructions or credentials, an unfinished app, and storefront apps that do not use theme app extensions. Shopify notes that resubmitting slows the review down. Some ideas fail before review because the requirements exclude them. Among others, an app must not be identical to another app the same developer has published, apps that do not use or need any Shopify API are not permitted, admin extensions must provide novel, feature-complete functionality, and checkout extensions must provide novel functionality or content. Shopify also lists categories it does not accept, such as apps that connect merchants to agencies and freelancers or connect to a point-of-sale system outside Shopify. Review explains delays and some dead ideas. It does not explain the zero-review majority: those apps passed review and were published. **Where platform changes created demand** Demand is easiest to see where Shopify retired an old way of customising a store and named a replacement. Shopify Scripts were deprecated on 30 June 2026 and Shopify points merchants to Shopify Functions. Non-Plus stores had until 26 August 2026 to upgrade their Thank you and Order status pages, and Shopify's help centre says an incompatible app keeps working only if its developer updates it; otherwise the merchant must contact the developer or install an alternative. One rule turns these changes into a market for public apps. Shopify's documentation says stores on any plan can use public App Store apps that contain functions, while only Shopify Plus stores can use custom apps built on Function APIs. A merchant below Plus who needs custom payment, delivery, discount or validation logic therefore has to install a public app. The checkout rules category shows what that looks like. The first three pages of App Store search for checkout rules, after removing unrelated results, held 52 apps with 3,005 reviews. The three apps with the most reviews were launched in September 2024, April 2025 and August 2024, so the category leaders are young. A reset category gives a newcomer a real chance that an old category like product reviews does not. The same niche also shows the limit. Over the 90 days to 19 October 2026, SMART Checkout Rules & Upsells received 256 new reviews, Blockify Checkout Customizer 29, Kedra Checkout Rules & Upsells and Payflow: COD & Checkout Rules 7 each, and BeSure Checkout Rules 2. One app took about 85% of the new reviews among these five, and that app's listing showed no paid plan. A young category can still be won quickly, and the winner can set the price of the job at zero. **How to test a niche before building** Four checks, all on public pages, would have caught most of the failures above. First, count the flow, not the stock: new reviews per app over the last 90 days, read from the dated review pages. Kedra Checkout Rules & Upsells shows why. It was launched in April 2025 and had 511 reviews, but only 7 in the last 90 days, while an app with 810 lifetime reviews took 256. Lifetime totals hide which app merchants are choosing now. Second, see who captures the flow. If one app takes most new reviews, the niche has demand and an owner. If no app in the niche gets new reviews, the niche may have no demand at all. A niche worth entering has new reviews spread across several apps, or a leader whose flow is falling. Third, read the leader's 1-2 star reviews and keep the denominator. Against Kedra's 511 reviews we found 13 at one or two stars, and the readable ones described rules that stopped applying at checkout and support that did not answer. That points to reliability and support as the gap, not missing features, and 13 out of 511 is a small base on which to build a product. Fourth, check the price of the job and the platform rule underneath it. If the leader is free, a paid entrant needs a different job, not a better version of the same one. If the product depends on a Shopify surface, find the sentence in Shopify's documentation that says the surface does the exact thing, and which plans can use it. Review counts remain a proxy: Shopify does not publish installs or revenue for third-party apps, and many merchants never leave a review. **Where the reports were wrong** 1. The share of apps with zero reviews. Our starting reports gave 44.8%. We traced that figure to a reply in a Shopify Community thread that attributes it to another post, with no method. Our own random sample found 62%, and our August search-card collection gave 56%. The direction was right; the figure was not sourced. 2. The size of the store. One report gave about 17,600 active apps and 12.4 million reviews, from a third-party statistics page that itself gives four different app totals. The App Store's sitemap listed 28,639 apps on 19 October 2026, and the review counts we collected in August summed to about 839,000. We could not reconcile the 12.4 million with anything on the store. 3. A performance requirement stretched to every app. One report said Built for Shopify requires API responses under 500 milliseconds for 95% of requests. The requirements set that bar for checkout requests and for carrier-rate endpoints, not for every app's API. 4. What an App Store click costs. One report said the bid is the fixed price of every click and gave starting costs of $60 to $75 per click in competitive categories and about $6 elsewhere. Shopify confirms a first-price auction, but its own example shows relevance lowering the price of a top position, and we found no primary source for either cost range. 5. Undocumented ranking thresholds. The reports said an app needs 10 reviews in its first 30 days to be visible, that Shopify scores engagement in the first 72 hours, and that answering support within two hours lifts rank. All of these traced to a Reddit post and marketing blogs. Shopify documents none of them. 6. The number of Built for Shopify apps. One report gave 1,443 apps with the badge and 98% of featured apps. We could not confirm either at a primary source; the third-party page we checked said more than 1,200. We report only our sample share. **Method** On 19 October 2026 we downloaded the App Store's English sitemap, drew a random sample of 400 app slugs with a fixed seed, and opened each listing with a pause of more than a second between requests. From each page we read the review count from the page's structured data, the launch date and the Built for Shopify badge. For concentration we used review counts collected from App Store search result cards on 5 August 2026. For the checkout rules and product reviews niches we read the first three pages of App Store search, removed unrelated results, counted dated reviews over the last 90 days and read the 1-2 star reviews of one app. Platform facts were checked the same day in Shopify's developer documentation on revenue share, App Store ads, Built for Shopify and its requirements, App Store requirements, the review process and common review problems, and Shopify Functions, and in the Help Center pages on the Thank you and Order status upgrade and on Shopify Scripts. We started from three AI deep-research reports written for internal use. We dropped their recommendations and any figure we could not trace, and list the errors above. **Limitations** A sample of 400 apps gives the main shares with a margin of about five percentage points either way at 95% confidence; the intervals are wider for the smaller subgroups, such as apps launched in 2025. Reviews are a proxy for customers: free apps collect reviews without revenue, and paid apps for larger merchants can have customers who never review. Launch dates on listings may reflect relaunches. The August and October counts come from different collection methods, search cards and the sitemap, so the growth figure compares two of our own counts rather than a published Shopify total. Velocity in one niche is one example, not a rule, and the checkout rules leaders may change within weeks. ### Who makes print-on-demand products for Romanian stores in 2026, what it costs to start, and which rules apply Romanian online stores can sell printed products without owning a printer, through EU-based platforms such as Printful, Printify and Gelato, or through Romanian workshops; the public accounts of the local players we checked show steady, modest growth rather than a boom. Owning equipment means prices that dealers mostly give only on request, and the PNRR can no longer fund it: as of 19 October 2026 the open money is regional EU funding with short windows. Printed products still carry the full set of EU labelling and product-safety rules. **The short answer** Print-on-demand (POD) means a product is printed only after a customer orders it, and shipped by the producer under the store's brand. A Romanian store has two routes. The first is an international platform: Printful ships to Europe at flat rates, Printify routes orders to independent print providers, and Gelato routes them to partners in 32 countries. The second is a Romanian workshop, which is closer to the buyer but has a narrower catalogue. We checked the public accounts of three Romanian companies in personalised print at the Romanian tax authority, ANAF. All three were profitable in 2025, at very different sizes. On the other side of the market, the firms our reports listed as failures were harder to document than the reports suggested. Of three cases we checked, two have a public record of fiscal inactivity or removal, and the third does not. This article covers the players, demand by segment, the cost of equipment, the funding that is open on 19 October 2026 and the labelling rules for printed goods. **International platforms with production in Europe** Printful prints in its own facilities. Its shipping page lists flat rates for Europe in euros: €4.49 for the first t-shirt plus €1.25 for each additional one, and €6.59 plus €2.00 for hoodies. It gives an average fulfilment time of 2 to 5 business days and standard delivery in Europe of 5 to 20 business days after fulfilment. Printify owns no print facilities. It connects stores to independent print providers. Its pricing page lists 70+ print providers and 140+ facilities or fulfilment centres. Using it is free, and a Premium plan costs $39 a month, or $24.99 a month billed yearly, with discounts of up to 33% on products. Gelato also routes orders to partners. Its help centre says it has more than 140 print partners across 32 countries and that a seller cannot choose the country where an order is produced. We found no public statement that any of the three prints in Romania. In all three models the store pays the platform for production and shipping, and the platform does not collect money from the end buyer. A store that sells mostly on cash on delivery therefore pays for the product before the courier collects the cash. That is our reading of the model, not a platform statement about Romania. **Romanian players: what the public accounts show** We pulled the annual accounts of three Romanian companies from ANAF's public web service. The figures are net turnover and net profit in lei, as filed. WE ARE MONO SRL, which operates the Printoteca print-on-demand site according to the site's footer, reported turnover of 3,785,154 lei and net profit of 168,469 lei for 2025, with an average of 4 employees. Turnover was 2,052,528 lei in 2019. The Printoteca site lists Shopify among its store integrations. RUVIX MEDIA SRL, which runs a personalised clothing and gifts workshop in Bucharest according to its own website, reported turnover of 21,412,037 lei and net profit of 2,823,166 lei for 2025, with 35 employees. Its profit was higher in 2024, at 3,273,646 lei. In 2019 its turnover was 3,604,138 lei. STARGIFT SRL, a personalised gifts retailer, reported turnover of 11,572,743 lei and net profit of 1,162,847 lei for 2025, with 20 employees. It filed a profit every year from 2019 to 2025, and its turnover rose every year, from 4,533,688 lei in 2019. Three companies are not a market. The point is narrower: the businesses that sell personalised products directly to consumers, and that we could check, have grown over several years without a sharp jump. **Demand by segment: what can be measured** We found no official statistic on the size of the Romanian market for printed t-shirts, stickers and labels, or personalised gifts. The market sizes in our reports came from paid market-research summaries or had no source, so we left them out. Weddings are the one segment with a measured driver. According to the national statistics institute (INS), 101,442 marriages were registered in Romania in 2024, 9,680 fewer than in 2023. The marriage rate fell from 5.1 to 4.7 per 1,000 inhabitants. Each wedding usually needs invitations and other printed items, so a smaller number of weddings means a smaller pool of orders. This link is our inference; INS does not measure stationery. For textiles, labels and gifts, the best public signal we found is indirect: the accounts of the firms that sell them, as above. For labels, demand also follows the rules on what a label must say, covered below. **What the public record shows about firms that stopped** Our reports named several small Romanian firms in online print as failures. We checked three of them against ANAF's register and filed accounts. We do not name them here: closing a company is not proof that the business failed, and we could not open the insolvency bulletin (BPI) to see the procedures. Case one. A company registered in 2016, which our reports describe as a seller of photo books and gifts (we could not confirm its activity from a primary source), filed rising turnover and a loss every year we could see. Turnover was 250,317 lei in 2019, 477,833 lei in 2020 and 405,845 lei in 2021. The net losses were 171,419, 148,267 and 80,408 lei. At the end of 2021, its equity was negative at 683,140 lei, with debts of 765,916 lei. ANAF lists it as inactive from 14 February 2024 and removed from the register from 19 April 2024. Case two. A company whose name refers to photo wallpaper has been listed by ANAF as fiscally inactive since 25 January 2021. ANAF's service returns no filed accounts for it for 2019 to 2025. The common thread in case one is that sales rose while losses continued. That fits the explanation in our reports, that the cost of selling ate the margin, but the accounts alone cannot show why the losses happened. Our reports also said that buying expensive equipment without demand had sunk firms. The accounts we saw do not support that; neither firm showed significant fixed assets. **The cost of entry: equipment** Using a POD platform needs no equipment. Producing in-house does, and the three technologies the market talks about are DTF (direct-to-film transfers for textiles), UV printing on rigid objects, and digital presses or print-and-cut machines for stickers and labels. Public prices are scarce. Most dealers we found quote on request, and the list prices in our reports came from North American or UK resellers, not from dealers selling in Romania. One verifiable data point is the used market: on the day we checked, Exapro listed a 2024 Epson SureColor SC-F2200, a DTG and DTF garment printer, at €8,990, located in Slovakia. Treat this as one listing, not a market price. We did not find a public Romanian list price for a UV flatbed printer or for a print-and-cut machine, so we give none. A budget should start from written quotes, and include consumables, ventilation and service, which our reports flagged but did not price. **Funding open on 19 October 2026** PNRR is closed for new projects. The EU regulation behind it requires milestones and targets to be completed by 31 August 2026 and payments to be made by 31 December 2026. Any PNRR equipment scheme cited in older articles is no longer a route for new applications. Regional EU funding has short windows. On the day we checked, the North-East regional development agency (ADR Nord-Est) listed the call Investments for the modernisation of microenterprises, call 2 as open from 28 September 2026 to 28 October 2026. The applicant's guide sets grants of 100,000 to 300,000 euros as de minimis aid, with at least 10% own contribution, for microenterprises with their seat and investment in the region. Eligible activity codes are in a separate annex, which we did not check for printing. Each of Romania's development regions runs its own programme and calendar, and we checked only one. We found no official announcement of a new Start-Up Nation call for 2026, only requests from business associations to launch one. **Labelling and product rules for printed goods** General product safety. Since 13 December 2024, the General Product Safety Regulation (EU) 2023/988 requires manufacturers to put on the product, or failing that on the packaging, a type, batch or serial number, and their name, trade name or mark, postal and electronic address. An online offer must show the manufacturer's name and contact details and a picture of the product. A store that sells under its own brand should check whether it counts as the manufacturer; that is a legal question, not something we can settle. Textiles. Regulation (EU) 1007/2011 requires textile products to be labelled with their fibre composition, in the language of the country where they are sold unless that country provides otherwise, and requires the composition to be visible before purchase, including online. Cosmetic labels. Printers of labels for cosmetics work to Regulation (EC) 1223/2009, which lists what the container must show: the responsible person, nominal content, durability date, precautions, batch number, function and ingredients. Regulation (EU) 2023/1545 added fragrance allergens to be labelled. Products that do not meet the new entries may be placed on the market until 31 July 2026 and made available until 31 July 2028. Labels printed to the old list are therefore a stock risk. Returns. The EU Consumer Rights Directive excludes from the right of withdrawal goods made to the consumer's specifications or clearly personalised. Romania transposed it through Emergency Ordinance 34/2014. A product only becomes non-returnable under this exception if it is actually personalised; a standard print from a catalogue is, in our reading, not covered. **Where the reports were wrong** 1. Currencies mixed up. One report gave Printoteca's operator 2025 turnover of 742,405 lei and profit of 33,043 lei. ANAF shows 3,785,154 lei and 168,469 lei. The report's figures match the euro equivalent, labelled as lei. Another report gave a gifts retailer's 2025 profit as 1,162,847 euros; ANAF shows 1,162,847 lei. 2. Headcounts. The same report gave that retailer 4 employees and built an argument about automation on it. ANAF shows an average of 20 in 2025. 3. An unconfirmed bankruptcy. One report presented a firm as a bankrupt canvas maker. ANAF lists it as registered and not inactive, under a clothing manufacturing code. Its turnover did fall to zero from 2022, but we found no primary document of insolvency, so we do not treat it as a failure. 4. Platform figures. The reports gave Printify more than 90 providers, Gelato more than 250 partners, and Printful a t-shirt rate to Europe of 4.99 dollars plus 1.45. The pages we read show 70+ providers, more than 140 partners in Gelato's help centre, and €4.49 plus €1.25. Gelato's own marketing pages use different partner counts, so any single number is a snapshot. 5. A used price. A report put a 2024 used SC-F2200 at 4,750 euros on Exapro. The listing we opened showed €8,990. Listings change; quote them with a date or not at all. 6. Funding. One report suggested PNRR-style grants for equipment in 2026. By regulation, PNRR milestones had to be met by 31 August 2026; on 19 October 2026 it is not a route for new projects. **Method** We started from nine AI deep-research reports on print-on-demand, online print failures, labels, wedding stationery, equipment, costs and labelling, written in Romanian for internal use. We removed internal recommendations and every figure about our own business, and kept the market, rule and platform claims. We checked each claim on 19 October 2026: company accounts and register status through ANAF's public web services, platform figures on Printful's, Printify's and Gelato's own pages, the marriage count in the INS publication on demographic events in 2024, the funding status on ADR Nord-Est's calls page and its applicant's guide, the PNRR dates in Regulation (EU) 2021/241, and the product rules in EUR-Lex. We could not verify and therefore dropped: market sizes for POD, stickers and labels; list prices for new DTF, UV and large-format machines in Romania; leasing terms; wedding spending per couple; the insolvency procedures listed in the reports; and several company figures that came only from business-data aggregators. **Limitations** ANAF's accounts show what companies filed, not why they grew or stopped, and we could not access the insolvency bulletin. Three active companies and two closed ones are examples, not a sample of the market. Platform prices, partner counts and second-hand listings change often. The funding picture covers one region on one day. The labelling section summarises regulations; whether a given store is a manufacturer under the product safety rules, or whether a given product is personalised enough to be non-returnable, is a legal judgement that this article does not make. ### How do ghost hunting and spirit box apps pass App Store and Google Play review? Neither Apple nor Google bans paranormal entertainment apps, and neither accepts a 'for entertainment purposes only' line as a defence for fake features. The apps that stay listed read real phone sensors, describe what they actually do, and say plainly that they do not detect spirits. The bigger obstacle on the App Store is guideline 4.3(b): a crowded category has to show a meaningfully different experience. **The short answer** Ghost hunting, spirit box and EVP apps are allowed on both stores, and the large ones sit in the Entertainment category. What gets an app rejected is not the theme but three things: features that present invented data as device readings, store copy that promises something the phone cannot do, and an app that looks like the dozens already listed. On the App Store, the relevant rules are 1.1.6 (false information and features), 2.3 and 2.3.1 (accurate metadata), 2.3.7 (names and keywords), 2.5.19 (hidden features), 4.2 (minimum functionality) and 4.3(b) (spam in saturated categories). On Google Play, it is the Deceptive Behavior policy. Both say in so many words that calling an app entertainment does not exempt it. This article is for developers building in this niche; it takes no position on whether paranormal phenomena exist. **What Apple's guidelines actually say** We read the App Review Guidelines as published on the day of writing; the page shows Last Updated: October 16, 2026. The words ghost, paranormal, psychic and astrology do not appear anywhere in it. The rules that touch this niche are general ones. Guideline 1.1.6 rejects 'false information and features, including inaccurate device data or trick/joke functionality, such as fake location trackers', and adds: 'Stating that the app is “for entertainment purposes” won’t overcome this guideline.' Guideline 2.3 asks that the description, screenshots and previews 'accurately reflect the app’s core experience', and 2.3.1(a) treats marketing 'content or services that it does not actually offer' as grounds for removal. Guideline 2.3.7 forbids packing metadata with trademarked terms or popular app names and says subtitles must not 'make unverifiable product claims'. Hidden or undocumented features are covered by 2.5.19. Guideline 4.3(b) is the one that bites most often in crowded niches. It lists dating, flashlight, sound effects, wallpaper, simple timers and fortune telling as well-established kinds of app and says Apple 'will not accept new submissions unless they offer a meaningfully different or improved experience', and may remove such apps if they are not updated or do not attract customers. Ghost hunting is not on that list. Whether a reviewer treats a spirit box as fortune telling is a judgement call, and we found no Apple text that settles it. Guideline 4.2 adds that an app without 'some sort of lasting entertainment value or adequate utility' may not be accepted. **The entertainment disclaimer: needed, not sufficient** Both stores reject the idea that a label fixes a deceptive feature. Our reading of the two texts together is that the disclaimer protects honest copy, not dishonest functionality: an app that shows real sensor values and says it cannot detect spirits is in a different position from one that draws a fake radar and calls it detection, whatever the fine print says. That is an interpretation, not a statement from either company. The listings of established apps show what honest copy looks like in practice. Ghost Hunting Tools ends its description with 'A note on entertainment': it is 'built for fun and curiosity' and 'We can't promise you will talk to a real ghost.' Ghost Camera Detector Radar says that 'since paranormal activity can't be scientifically proven, we can't guarantee that the app communicates with real spirits.' GhostTube calls the afterlife 'a theoretical concept' and says paranormal tools 'should never be relied on to make important life decisions, as a form of definitive communication, or to cope with grief or loss', and that generated words 'should never be interpreted as instructions or requests'. Its own website goes further: the idea that paranormal tools 'detect ghosts' is, in its words, 'absolutely false'. The grief clause is worth copying. The public reviews of these apps include users who are frightened by what an app said; on the day we checked, one recent review of a large app asked whether the writer was now haunted. A disclaimer that speaks to that reader, not only to the reviewer, is the part that does real work. **Google Play: the Deceptive Behavior policy** Google's Deceptive Behavior policy says apps 'must not misrepresent their functionality, or claim impossible features, even if labeled as a joke'. Its examples of violations include 'apps that claim functionalities that are not possible to implement, such as insect repellent apps, even if it is represented as a prank, fake, joke, etc.', and a phone app claiming to work as a breathalyzer. A separate clause states that a claim that an app is a 'prank' or 'for entertainment purposes' does not exempt it from the policies. The policy also requires that the title, icon, description and screenshots accurately reflect what the app does, and lists improper categorisation, including the wrong content rating or category, as a violation. For a spirit box, the practical reading is the same as on iOS: describe it as a sound and word generator driven by sensors or randomness, keep it in an entertainment category, and do not claim it receives anything. **How a spirit box works, and what an iPhone can do** A hardware spirit box is a radio receiver that sweeps through AM or FM frequencies quickly, so the listener hears static broken by fragments of broadcasts. An information sheet written by a co-developer of the P-SB7, one of the best-known devices, describes FM and AM settings and sweep speeds set in milliseconds, forward or in reverse. An iPhone app cannot copy this with the phone's radio. In September 2017 Apple told the press that iPhone 7 and iPhone 8 models 'do not have FM radio chips in them nor do they have antennas designed to support FM signals'. The US broadcasters' association disputed that, citing teardowns that found a Broadcom chip with an FM core. Either way, we found no public iOS API that gives an app an FM tuner, and we did not find an Apple statement about later models. So iOS apps use one of three substitutes. Some play short slices from banks of prerecorded sound: Necrophonic's listing describes eight sound banks of phonemes, partial words and reversed audio and says there are 'no real words of phrases' in them beyond basic phonetic sounds. Some cut between internet radio streams: GhostTube VOX describes snippets 'synthesized from real radio streams'. Some generate words from a built-in list and speak them; Apple's speech framework can render speech straight into an audio buffer, which an app can then process. The sensor most of these apps read is the magnetometer, which Apple's documentation describes as measuring 'the Earth’s magnetic field relative to the device'. It reacts to iron, motors and appliances. In our own test, running a hair dryer next to an iPhone produced a clear magnetometer disturbance lasting about 60 seconds. That is the honest description of what a phone 'EMF meter' shows: changes in the magnetic field near the phone, not a detection of anything else. **Permissions and the review itself** Sensor and microphone access have to be declared. Apple's documentation says the motion usage description is required for apps that read motion data and that without it 'your app will crash when it attempts to access motion data'; the microphone key is required for any API that accesses the microphone. Guideline 5.1.1 adds that apps should only request access to data relevant to their core function and must not trick or force people into unnecessary access. In our own pre-submission audit of an app of this kind, the most serious finding was a microphone prompt shown at launch, on a screen that never records. It was invisible on the test phone, where permission had been granted long before, and only showed up on a clean install. We moved the prompt to the record button. The same audit found that a recording mode captured the app's own synthesized voice from the speaker, which is the worst artefact an EVP feature can produce. Guideline 2.3.1(a) requires new features to be described 'with specificity in the Notes for Review section' and says generic descriptions will be rejected. For a sensor-driven app, the notes are the place to say what the reviewer will see on a desk, where the field barely changes, and how to make the app react, for example by moving it near a metal object. **The market: leaders, pricing and reviews** We read the US App Store listings through Apple's public lookup service on 19 October 2026. The largest by number of ratings were Ghost Camera Detector Radar with 55,411 ratings, Ghost Hunting Tools with 10,448, GhostTube SLS Camera with 9,653 and GhostTube with 7,048. All are free to download and sit in Entertainment. The pricing models differ. GhostTube lists a monthly subscription at $2.99, an annual one at $11.99 and a bundle subscription at $49.99. Ghost Hunting Tools lists $4.99 a month, $17.99 a year, and Pro purchases at $34.99 and $19.99. Spirit Contact Talker is a paid download at $7.99 with 4,780 ratings. Necrophonic is paid at $9.99, listed under Utilities, with 547 ratings and no update since 19 June 2022. Recent public reviews show where users feel cheated. Of the 50 most recent US reviews returned by Apple's review feed, Necrophonic had 24 one-star reviews, mostly about no sound, random or 'prerecorded' output and price. Ghost Hunting Tools had 18 one-star and 2 two-star reviews, many about ads interrupting sessions and buyers who said a Pro purchase stopped being recognised. The complaints are less about the paranormal premise than about value and reliability. **How these apps grow** Growth in this niche runs through video. Paranormal channels have large audiences: on 19 October 2026 the channel pages showed about 16.5 million subscribers for Sam and Colby, 4.47 million for OmarGoshTV, 1.4 million for Twin Paranormal and 350 thousand for Amy's Crypt. GhostTube's website describes its tools as used by 'prominent paranormal investigators and content creators' and as designed 'to be used and enjoyed on real investigations'. We could not confirm, at a primary source, any specific causal story of an app taking off after a given video, nor which creators are paid partners. What the listings do show is that the leaders pitch themselves as filming toolkits as much as detectors, which suits creators and keeps the copy away from claims of detection. **Where the reports were wrong** 1. An outdated 4.3(b). Our reports quoted 4.3(b) as saying saturated categories 'require a high-quality user experience to be approved'. The current text says Apple will not accept new submissions unless they offer a meaningfully different or improved experience, and may remove such apps later. The bar is higher than the report suggested. 2. The wrong guideline number for hidden features. The reports put the ban on 'hidden, dormant, or undocumented features' under 2.3.1. In the current guidelines it is 2.5.19; 2.3.1 is now about Notes for Review and misleading marketing. 3. Ghost apps as fortune telling. One report said reviewers group ghost detection apps with fortune telling 'in operational practice'. The guidelines do not mention ghost or paranormal apps at all, and we found no Apple source for that grouping. It may happen; it is not documented. 4. A rejection case overstated. A report cited a 4.3(b) rejection of a card-reading app with an exact date in August 2026 and called it a tarot app. The forum thread shows only August 2026, does not state the category, and the claim that the review lasted under a minute comes from the developer's own logs, with no reply from Apple. 5. Stale prices. A report listed Spirit Contact Talker as free with in-app purchases and Ghost Hunting Tools Pro at $29.99. The listings show Spirit Contact Talker as a paid download at $7.99 and Pro purchases at $34.99 and $19.99. 6. Origin stories. A report said GhostTube was created by the hosts of a YouTube channel and that another app took off after a large creator used it. We found no primary source for either, so both are left out of the findings. **Method** We started from six AI deep-research reports and an internal project note on paranormal entertainment apps. We removed product recommendations and internal figures and checked each fact we kept on 19 October 2026: the full text of the App Review Guidelines, Google Play's Deceptive Behavior policy page, Apple's developer documentation for the magnetometer, permission keys and speech synthesis, the US App Store listings and in-app purchase lists of the apps named, Apple's public review feed for recent reviews, the YouTube channel pages for subscriber counts, a forum thread cited as a rejection case, a co-developer's information sheet for the P-SB7 and the 2017 reporting of Apple's FM radio statement. We could not verify and dropped: sweep-rate figures for hardware spirit boxes, which differ between retailer listings; Apple Ads keyword popularity scales; the internal working of a radio-stream spirit box app that the reports described in detail; and several app origin and growth stories. **Limitations** Review outcomes depend on individual reviewers, and we did not submit an app for this article; the reading of how the guidelines apply is ours. Listing figures move weekly and are US-store figures only. The review counts come from the latest 50 reviews in Apple's feed, which over-represent unhappy buyers and are not a satisfaction rate. Subscriber counts measure audience, not influence on downloads. The FM statement covers two iPhone generations and was disputed; the absence of a public FM API is based on our search of Apple's documentation, not on an Apple statement. ### How do you take a deposit on Shopify in 2026: part of the payment now, the rest later Shopify has a native deposit mechanism only for pre-orders and try-before-you-buy, and it works only through an app that schedules and charges the balance itself. Deposits at checkout through payment terms, deposits on draft orders and partial payments in the admin are limited to Shopify Plus. Everything else comes from apps, whose low-rated reviews point to the same weak spots: billing, storefront scripts and the second payment. **The short answer** On Shopify, a deposit with the balance charged later has one native route open to every plan: a pre-order or try-before-you-buy purchase option, set up by an app. The app tells Shopify how much to charge at checkout, Shopify stores the buyer's payment method, and the app is responsible for charging the rest at the right time. Shopify does not run that second charge on its own. Three other routes exist and all are tied to Shopify Plus: payment terms with a deposit set at checkout by a payment customization function, deposits on draft orders (new in the 2026-07 API version), and partial payments collected from the order page. Authorizing a card and capturing it later is not a deposit: the authorization lasts days, not months. This article is for merchants who sell made-to-order, high-value or not-yet-available goods and for people building tools for them. We checked platform facts in Shopify's developer documentation, help centre and changelog, and app facts on the App Store listings and their 1 and 2 star reviews, on 19 October 2026. **The native route: a pre-order with a deposit** Shopify groups subscriptions, pre-orders and try-before-you-buy (TBYB) under purchase options, built on selling plans. For pre-orders and TBYB, the selling plan's billing policy can set a checkout charge: a partial amount taken at checkout, with the remaining balance due on an exact date or a number of days after checkout. A checkout charge of 0 means the full amount is charged at checkout. Shopify's own example is a pre-order with a 10% deposit and the balance charged on the fulfilment date. The selling plan also controls inventory: stock can be committed when the order is placed or when it is fulfilled. Each order can carry only one deferred due date. If a cart mixes products with different due dates, the earliest applies, and a line due on fulfilment takes precedence over a fixed date. In the 2026-01 API version, Shopify added ON_FULFILLMENT as a trigger for the remaining balance on pre-orders, so the balance can be due when the order ships instead of on a fixed date. **Where the pre-order route does not reach** A merchant needs a pre-order app from the App Store; Shopify's help centre says so directly. The app itself needs protected API scopes approved by Shopify, and custom apps created in the Shopify admin cannot use pre-orders or TBYB at all, because they cannot request those scopes or use extensions. Shopify's pre-order setup page lists the restrictions a merchant will meet. Pre-orders are not supported on stores with customized checkouts. Buyers cannot use Shop Pay, Apple Pay or Google Pay accelerated checkouts for pre-orders, nor local payment methods such as Klarna and iDEAL. Pre-order products work only on the Online Store and custom storefront channels, Buy X get Y discounts do not apply, and a product cannot be bought with two purchase options in the same checkout. The developer documentation adds that pre-orders and TBYB cannot be used on draft orders, with B2B, or through Shopify POS. On payment gateways the two sources disagree: the help centre says pre-orders are available only with Shopify Payments or PayPal Express, while the developer documentation also lists Adyen on Shopify and, for some merchants, Stripe. The same setup page carries a policy obligation that matters for long lead times: the merchant needs a reasonable basis for the promised shipping time, must assume shipping within thirty days when no date is stated, and must give a revised date plus cancellation and refund options if it slips. **Collecting the balance: who charges and when** Shopify's documentation is explicit about the division of work. Shopify stores the customer's payment method so the merchant can collect the balance without contacting the customer. Scheduling the charge at the right time and handling failed payments is the app's job. The charge itself goes through orderCreateMandatePayment, which uses the stored payment mandate to collect the balance or create a payment schedule. It requires a unique idempotency key, so a retried request returns the first result instead of charging twice. One detail narrows it: to set a custom amount on that charge, the app must be installed on a Shopify Plus store. On other plans, the app charges what the payment schedule says is due. In practice this means the reliability of the second payment depends on the app: its scheduler, its retry logic when a card fails, and its emails asking the buyer to update the card. That is also where the low-rated reviews below concentrate. **The Shopify Plus routes: payment terms, draft orders, partial payments** Payment terms at checkout. Since the June 18, 2025 changelog entry, a payment customization function can set fixed, net or event-based payment terms with an optional deposit, for example a deposit on high-value orders. The operation that sets terms is available only on Plus. The tutorial adds a limit for consumer stores: a direct-to-consumer checkout with payment terms offers no pay-now option, only deferral, and vaulted cards as an extra deferred option are a B2B feature. Draft orders. As of API version 2026-07, published July 1, 2026, an app can set a deposit when it creates or updates a draft order, and the Customer Account API shows the buyer the amount due now and the amount due later. The changelog states that the feature is available to Shopify Plus stores. This is the route that fits quotes and custom work sold by a person rather than through the storefront. Partial payments in the admin. Shopify's help centre describes collecting or recording several partial payments on an order with payment terms, by a new credit card, a manual method such as a bank deposit, or a vaulted card for B2B customers. The page states that the feature is available to stores on Shopify Plus. Vaulted cards for B2B. Card vaulting for company locations is available only with Shopify Payments. A vaulted card is not charged automatically when the payment terms expire; the merchant charges it from the order page, or automates the charge with Shopify Flow. **Authorize now, capture later is not a deposit** Manual capture is often suggested as a deposit substitute. It reserves the money on the card and collects it later, but only within the authorization period. Shopify Payments gives 7 days for card payments. Extended authorization exists only for Plus stores using Shopify Payments and depends on the card: up to 30 days for Visa, Mastercard and American Express, up to 10 days for Discover and JCB. Capturing after the standard 7 days adds a 1.75% charge. For goods made over several weeks, this does not cover the gap, and it holds the full amount rather than a deposit. **What the apps add, and what their low-rated reviews say** Apps fill the gaps above: a deposit or pay-later button on the product page, their own schedule for the balance, reminder emails, and for some, deposits on draft orders. We read the 1 and 2 star reviews of three established apps in this category, checked on 19 October 2026. Low ratings are a small share of each app's reviews, several are old, and developers dispute some of them, so they show where problems occur, not how often. PreOrder App+Partial Payment K, by the developer Kaktus, showed 4.7 from 396 reviews, 12 of them 1 star and 2 of them 2 stars. Its paid tiers are set by total Shopify orders per month, from $9.99 for up to 9 orders to $29.99 for up to 150, not by orders that used the app. The most specific complaint, from December 2025, describes a $115 charge in a month when one $25 order came through the app. Other reviews describe pre-orders whose second payment did not complete, the widget not loading so buyers went through a normal checkout, a removed add-to-cart button and inventory left committed after fulfilment. Deposit & Partial Payment Depo showed 4.4 from 98 reviews, with 11 at 1 star and 2 at 2 stars. The listing prices plans at $9, $49 and $149 a month plus a processing fee per transaction, and advertises deposits on draft orders and cash-on-delivery support. A July 2026 2 star review from a merchant who processed hundreds of pre-order payments describes a slow script that let buyers check out at full price before it loaded, pre-order orders created after the app was disabled but not uninstalled, and no confirmation email when the balance was paid. A January 2025 review says the processing fee also applied to manual orders marked as paid. SPD Split Payment & Deposit showed 4.8 from 145 reviews, 6 at 1 star and 4 at 2 stars. Its tiers run from $19 to $49 a month by order value processed with the app, and the listing advertises deposit auto-charge. A July 2026 review describes repeated breakage from conflicts with other installed apps; a September 2025 review describes instalment orders missing from the app and balance invoices not sent. Across the three, the complaints fall into four groups: billing that feels detached from use (store-wide volume, fees on orders the app did not handle, a free label that meant only development stores), storefront scripts that load late or clash with the theme or other apps, the balance stage (failed second charges, missing emails and invoices), and back-office gaps such as stuck inventory or missing records. **Invoices and VAT on deposits in the EU: the principle** Under the EU VAT Directive, when a payment is made on account before goods or services are supplied, VAT becomes chargeable on receipt of that payment and on the amount received (Article 65). Member States may set different chargeability rules for certain transactions (Article 66). The Directive requires an invoice for a payment on account received before supplies to businesses and non-taxable legal persons and before distance sales of goods (Article 220), and Member States may require invoices for other supplies too (Article 221). The usual result is an advance invoice for the deposit and a final invoice that deducts it, but the exact obligations depend on national law. This is a description of the EU framework, not tax advice. How a deposit is invoiced, and how a refunded deposit is corrected, should be confirmed with an accountant in the country where the VAT is due. None of the Shopify pages we read describes issuing tax invoices for deposits. **Where the reports were wrong** 1. Payment gateways for pre-orders. The report said pre-orders work only with Shopify Payments or PayPal Express. That matches the help centre, but Shopify's developer documentation also lists Adyen on Shopify and, for some merchants, Stripe. Shopify's own pages disagree; the gateway provider is the one to ask. 2. Stale prices. The report said Kaktus pricing starts at $29.99 a month. The listing starts paid tiers at $9.99; $29.99 is the tier for up to 150 orders. It also listed a free tier for SPD; the listing shows paid tiers from $19, free only for development stores, and an August 2026 1 star review complains about exactly that. 3. Stale review counts. The report gave Kaktus 380 reviews, Depo 92 at 4.5 and SPD 127. On 19 October 2026 the listings showed 396, 98 at 4.4, and 145. 4. Due on fulfilment. The report treated due-on-fulfilment pre-orders as stable from 2026-01. The changelog says the value was added in the 2026-01 release candidate, while the pre-order documentation page still describes it as unstable and planned for 2026-01. Check the API version in use before relying on it. 5. Market sizing and forecasts. The report estimated demand from United States business statistics and projected revenue for a new app. Neither describes Shopify merchants, and we could not check the forecasts against anything, so they are not in this article. **Method** We started from an AI deep-research report on the feasibility of a Shopify deposit app. It mixed platform facts, app listing data, market sizing and product recommendations. We removed the recommendations and forecasts, kept the platform and app facts a merchant would act on, and checked each on 19 October 2026: Shopify's developer documentation on purchase options, deferred payments, orderCreateMandatePayment and payment customization; the payment terms tutorial; the developer changelog; help centre pages on pre-orders, partial payments, payment authorization and B2B vaulted cards; and the consolidated EU VAT Directive on EUR-Lex. For apps, we opened the App Store listings for ratings, review counts and pricing, and read the newest 1 star reviews (up to ten per app) and all 2 star reviews for Kaktus, Depo and SPD. We did not install the apps. We could not verify, and dropped: the number of Shopify Community threads asking for deposits, the demand estimates for furniture, bakeries, events and wholesale, the revenue projections, and the ratings and features of three newer apps the report named, which we left out to keep the source list to primary pages we cited. **Limitations** Low-rated reviews are a biased sample: unhappy merchants write them, some are years old, and the apps may have fixed the issues since. The share of low ratings is small for all three apps. We did not test any app or the native pre-order flow on a live store, so this article describes what Shopify and the vendors document, not measured behaviour. Plan and gateway rules change; Shopify's own pages disagree on one point already. The VAT section describes the EU Directive only; national rules, including invoice timing and content, differ and are not covered. ### How do Shopify stores use WhatsApp in 2026: Meta's official app, per-message pricing, opt-in and COD confirmation Shopify stores connect to WhatsApp either through Meta's official Cloud API, used by Meta's own app and many third-party apps, or by automating a number linked as a device, the route behind the account bans merchants report. Since July 2025 Meta charges per delivered template: in Romania $0.086 for marketing and $0.029 for utility messages such as order confirmations. Abandoned-cart reminders count as marketing and need marketing consent. **The short answer** A Shopify store reaches customers on WhatsApp in one of two ways. The supported way is the WhatsApp Business Platform, through Meta's Cloud API: Meta's own Shopify app, or a third-party app built on the API. The other way links an ordinary WhatsApp number as a device, the way WhatsApp Web does, and automates it. Every merchant complaint we read about banned or restricted numbers came from the second kind. On the official route, Meta has charged per delivered template message since July 1, 2025. On the rate card effective October 1, 2026, a marketing message to a Romanian number costs $0.086 and a utility message, such as an order confirmation, $0.029. Replies inside the 24-hour window that opens when a customer writes are free. An abandoned-cart reminder is a marketing message by Meta's own definition, so it costs the marketing rate and needs marketing consent. This article is for merchants who sell on Shopify in Romania and the EU, many of them on cash on delivery, and for people who build tools for them. Every platform fact was checked in Meta's documentation, every app fact on the app's Shopify App Store listing, and every legal fact in the text of the law. **Two ways to connect a store to WhatsApp** The WhatsApp Business Platform is Meta's product for businesses that send messages from software. The store sends pre-approved templates first, the customer can reply, and Meta bills per message. Apps either connect a store's number directly to Meta's Cloud API or go through a partner that does. Meta's own app, WhatsApp by Meta, was launched on the Shopify App Store on July 11, 2025. When we checked, it showed 4.4 stars from 912 reviews, was free to install, and stated that external charges may be billed by Meta separately from the Shopify invoice. The listing describes order updates, abandoned-cart marketing messages and pre-built or customised templates. The second route uses a personal or WhatsApp Business app number linked as a device. WhatFlow: Whatsapp Automation says on its listing that it connects in minutes using WhatsApp link-a-device; it showed 3.9 stars from 337 reviews. Its developer has since published a separate app, WhatFlow WhatsApp Business API, launched on May 8, 2026, with 5.0 stars from 47 reviews. SL: WhatsApp Chat + Marketing offers both: messages from a personal number with no API, and automation through the API. WhatsApp's terms of service for the EEA forbid using the service through automated means in impermissible or unauthorized manners. Whether a given link-a-device tool falls under that is for Meta to decide, not for us. The reviews show what happens in practice, and we come back to them below. **What Meta charges in 2026** Meta's pricing page says it moved from per-conversation to per-message pricing on July 1, 2025. A business is charged when a template message is delivered. The rate depends on the template category, marketing, utility or authentication, and on the country code of the recipient's number. Three things are free. Non-template messages, such as plain text or images, are free inside an open customer service window, which lasts 24 hours from the customer's last message. Utility templates sent inside that window are also free. And when a customer writes through a Click to WhatsApp ad or a Facebook Page call-to-action, and the business answers within 24 hours, a 72-hour free entry point window opens in which all messages, templates included, are free. Volume tiers lower the price of utility and authentication messages as monthly volume grows. They do not apply to marketing messages. Meta also lists, for 2026, higher utility and authentication rates for Romania from July 1, 2026, and a feature that lets businesses on the Marketing Messages API set a maximum price per marketing message. **Romania and the EU: the rates** The figures below come from Meta's rate cards in USD and EUR effective October 1, 2026, linked from the pricing page. Romania has its own rates: $0.086 for marketing and $0.029 for utility and authentication, or €0.0712 and €0.0239 on the euro card. Poland is far cheaper, at $0.0366 and $0.0122. Germany, at $0.1365 and $0.055, and the Netherlands, at $0.1597 and $0.05, are the most expensive markets we looked at. Bulgaria, Greece, the Czech Republic, Slovakia and Croatia are among the countries priced together as Rest of Central and Eastern Europe: $0.086 for marketing and $0.0212 for utility. Hungary has its own rate, $0.086 and $0.035. For Romania, the first volume discount on utility messages starts at 100,001 messages a month and is 5%. The largest discount, 25%, applies above 80 million. A small store pays the list rate. At list rates, 1,000 order confirmations to Romanian numbers cost $29 and 1,000 abandoned-cart reminders cost $86, before any app fee. **Templates and categories: what counts as utility** Every message a business starts must use a template approved by Meta. Meta's template guidelines define utility as non-promotional and tied to something the customer requested or to their account. Under order management they list confirming, updating or cancelling an order, with the example Your order {{order_number}} is confirmed. A cash-on-delivery confirmation that asks the buyer to confirm or cancel fits this description. Abandoned-cart reminders are the other way round. Meta's guidelines list You left {{items}} in your cart! as retargeting and state that such messages are marketing even if requested by users. Authentication is reserved for one-time passcodes and needs a one-time password button. Mixing content changes the category. An order update with a promotion is marketing. Since April 9, 2025, a template submitted as utility that Meta judges to be marketing is approved as marketing. An approved template can also be moved to marketing later, with one day's notice, and a review can be requested within 60 days. A coupon added to an order confirmation can therefore turn a $0.029 message into a $0.086 one. **Opt-in, messaging limits and coexistence** Meta's opt-in rules require the business to state clearly that the person is opting in to messages from it, and to name the business. The opt-in does not have to mention WhatsApp, as long as the business complies with local law. It can be collected on a website, by SMS, by phone or on paper. Separate opt-in per message category, and clear opt-out instructions, are recommended rather than required. Messaging limits count unique customers reached outside a customer service window in a moving 24-hour period. They apply per business portfolio, so all numbers in a portfolio share one limit. A new portfolio starts at 250; the next levels are 2,000, 10,000, 100,000 and unlimited. Business verification is one of three paths to 2,000. Above that, the limit rises automatically if message quality is high and at least half the current limit was used in the last 7 days. A number used in the WhatsApp Business app no longer has to leave it. Meta's coexistence onboarding lets the same number work in the app and on the Cloud API at once, from app version 2.24.17. The limits matter for stores: throughput is capped at 20 messages per second, broadcast lists in the app are disabled, and catalogs, orders, labels and away messages are not supported on the Cloud API side. Up to 6 months of chat history can be synchronised. **Marketing messages and EU privacy law** This section is our reading of the texts, not legal advice. Article 13 of the EU ePrivacy Directive allows electronic mail for direct marketing only with prior consent, and the Directive defines electronic mail as any text, voice, sound or image message sent over a public communications network. Paragraph 2 allows a seller to market its own similar products to existing customers whose electronic contact details it obtained in a sale, as long as they can object free of charge at collection and in every message. Romania's transposition, Law 506/2004, is wider in one respect. Article 12(1) bans commercial communications by email, fax, automated calling systems, or any other method that uses publicly available electronic communications services, without the recipient's prior express consent. The existing-customer exception in Article 12(2) is written for the customer's email address. Whether it covers a WhatsApp number is a question of interpretation; we found no Romanian decision on it. The GDPR adds two rules that apply whatever the channel. Consent can be withdrawn at any time and withdrawing must be as easy as giving it (Article 7(3)). A person can object to direct marketing at any time, and their data may then no longer be used for it (Article 21(2) and (3)). The cautious reading for a Romanian store is to collect a separate, explicit WhatsApp marketing consent before sending abandoned-cart or campaign messages, and to treat order updates as part of fulfilling the order. **What the reviews complain about** Meta's app had 90 one-star and 24 two-star reviews when we checked. The recent ones repeat a few themes: order updates not delivered despite customer opt-in, a lost connection to the WhatsApp Business account with no warning, months spent on verification, order tags for confirmed or cancelled orders not appearing in Shopify, templates sent in English instead of the chosen language, a marketing opt-in that subscribed customers to SMS but not WhatsApp, and slow support. Meta's replies point merchants to an in-app support link, and in October 2026 one reply announced that abandoned-cart support had been added. On the link-a-device app, 16% of ratings were one star. Reviews from 2026 report disconnections, a WhatsApp Business account restricted by Meta, and one store that wrote its account was banned twice for using an unauthorized API. The developer's replies, dated August 26, 2026, attribute the problems to web-pairing sessions and point merchants to its new official-API app. On SL: WhatsApp Chat + Marketing, 29% of ratings were one star. The complaints there are mostly about billing that continued after uninstalling or without use, cart-recovery automations that stopped, and support that did not answer. Among apps built on the official API, Dondy: WhatsApp Marketing+Chat, which carries the Built for Shopify badge, showed 4.9 stars from 830 reviews, and Zoko WhatsApp Marketing & Chat, from $49.99 a month, showed 4.9 from 462. High averages tell you less than the low-star reviews do: those show where an integration breaks. **Where the reports were wrong** 1. Messaging limits. The report said unverified accounts are capped at about 250 conversations a day and verified ones move from 1k to 10k to 100k. Meta's current page counts unique customers outside a service window, applies the limit per business portfolio, and lists 250, 2,000, 10,000, 100,000 and unlimited. Verification is one of three ways to reach 2,000, not the only one. 2. One number, one app. The report said a number on the API can no longer use the WhatsApp Business app. Meta's coexistence onboarding allows both at once, with the limits described above. 3. COD confirmations as authentication. The report said COD confirmations are utility or sometimes authentication. Authentication templates are only for one-time passcodes. A confirm-or-cancel order message is utility, and becomes marketing if it carries a promotion. 4. Stale rates and tiers. The report's Romanian, Polish and regional rates match the current card, but its US utility rate of about $0.005 is out of date: North America utility rates fell on January 1, 2026, and the card now shows $0.0034. The report also implied volume discounts apply generally; they cover utility and authentication only. 5. Listing figures. The report gave Meta's app 4.3 stars from 580 reviews; the listing showed 4.4 and 912. It gave SL 3.3 from 333; the listing showed 3.5 and 351. Other counts had also grown. Quote listing numbers with a date. 6. Return-on-investment figures. The report gave confirmation rates of 70 to 90 percent, a return to origin falling from 22 to 10 percent, a ninefold return on spend and a 15 to 20 percent drop in failed deliveries from address checks, all from vendor case studies. We could not trace any of them to a primary source, so they are not in this article. **Method** We started from an AI deep-research report on WhatsApp apps for Shopify stores that sell on cash on delivery. It mixed App Store listing data, Meta pricing, vendor case studies and a product pitch. We removed the pitch and the feature-priority analysis and checked the rest on October 19, 2026: Meta's developer pages on pricing, opt-in, messaging limits, template categories and coexistence onboarding; Meta's USD and EUR rate cards effective October 1, 2026; WhatsApp's EEA terms of service; the Shopify App Store listings and one- and two-star reviews of the apps named; and the texts of the ePrivacy Directive, Romania's Law 506/2004 on legislatie.just.ro and the GDPR. We could not verify, and dropped: the vendor case-study figures listed above, the claim that merchants tolerate 5 to 10 US cents per confirmation message, and listing figures for several apps whose pages we did not open. When we opened Meta's listing from Romania, it showed a line reading Based in Romania under Popular with stores like yours, while the developer address is in Seattle; we read that line as describing the stores that use the app, not the developer. **Limitations** Meta changes its rates every quarter, and the rate card we read had been in force for under three weeks. Re-check the card before building a budget on it. App listings describe what the vendor says the app does, and one- and two-star reviews are a biased sample by design: they show failure modes, not failure rates. The legal section is our reading of the texts; we found no Romanian ruling on WhatsApp marketing, and a lawyer may read Article 12(2) differently. We did not test any of the apps on a live store for this article. ### How do you convert a PDF bank statement into a file QuickBooks can import: QBO, CSV or OFX? QuickBooks imports bank transactions from files, not from PDFs. QuickBooks Online accepts CSV, QBO, QFX and OFX uploads of up to 350 KB and 1,000 lines; QuickBooks Desktop takes only Web Connect .qbo files, which Intuit ties to a bank with a contracted identifier. Converters fill the gap between the PDF and those formats, at prices from about $3 per statement to $100 a month, and the main risks are misread amounts, duplicate transactions and where the statement files go. **The short answer** QuickBooks does not read PDF bank statements. It takes transactions from a live bank connection or from a file: CSV, QBO, QFX or OFX in QuickBooks Online, and only the Web Connect .qbo format in QuickBooks Desktop. When the bank connection does not exist, does not reach far enough back, or the bookkeeper only has PDFs, someone has to turn the PDF into one of those files. That is the job statement converters sell. This article is for bookkeepers, accountants and small businesses deciding how to get old or unconnected statements into QuickBooks, and for people building tools for them. We checked the QuickBooks rules in Intuit's help centre, the file format in the OFX specification, and the prices on each vendor's own pricing page, on 19 October 2026. **Why the demand exists** Intuit's help centre says download timespans for a connected bank account vary by bank, ranging from 90 days up to 24 months. Anything older has to come from a file. The same article says that when a bank or card company does not provide a connection to QuickBooks, downloading transactions as a file is the only option. The file route assumes the bank offers a usable download. Often the person doing the books has something else: PDF statements sent by a client, statements from a closed account, or a year of history for a catch-up job. A PDF is a page layout, not a list of transactions, so it has to be converted first. On QuickBooks Desktop the gap is wider. Intuit's Desktop documentation describes bank imports through Web Connect .qbo files and says QFX and QIF files will not work; it describes no CSV route for bank transactions, and a QuickBooks Community answer from November 2023 states that Desktop cannot upload CSV files for banking. **What a .QBO file actually is** A .qbo file is an Open Financial Exchange (OFX) statement that QuickBooks treats as a Web Connect download. OFX is an open standard first published in 1997 and now maintained by the Financial Data Exchange (FDX). The current banking specification, version 2.3, is dated October 2020 and is licensed royalty-free to anyone building conforming products. In the specification, each transaction is a STMTTRN block with a transaction type, a posting date (DTPOSTED), a signed amount (TRNAMT) and an identifier (FITID). Amounts are signed from the customer's side: a card purchase is negative, a card payment positive. The statement also carries a ledger balance. A converter that flips a sign or drops the balance produces a file that may import but does not describe the account. What makes a file a QuickBooks file rather than plain OFX is the bank identity. Intuit's page for financial institutions says Web Connect and Direct Connect are directly associated with a contracted Intuit BID (Branding ID), and that Web Connect requires a contract with Intuit or with the bank's online banking provider. In .qbo files this identifier sits in an Intuit-specific tag, INTU.BID, which does not appear in the OFX 2.3 specification. **FITID: the field that decides duplicates** The OFX specification gives FITID one main purpose: to let the client detect duplicate downloads. FITIDs must be unique within an account, need not be sequential, and may be up to 255 characters, though the specification recommends 32 or fewer. They are not unique across banks, so software keys on bank plus account plus FITID. QuickBooks Desktop relies on this. Intuit says you cannot download the same transactions into the same Desktop company file more than once, and that once downloaded transactions are deleted, they cannot be downloaded again. For a converter this cuts both ways, in our reading of the specification. If it generates new random IDs every time, converting the same statement twice can create duplicates. If it reuses an ID for two different transactions, QuickBooks may treat a real transaction as already imported. The safe design is an ID derived deterministically from the transaction and the account. **What breaks at import** Intuit's article on Web Connect import errors lists the messages users see: QuickBooks can't read the file the institution sent, an error occurred while processing your online banking data, your data was not imported, and QuickBooks is unable to verify the Financial Institution information for this download. Intuit attributes such failures to bank files that are incomplete or in a format Web Connect cannot read. Its fixes are to download a fresh file from the bank, switch bank feeds to Classic mode, test in a new company file and contact the bank. Importing .qbo files into Desktop also requires signing in to an Intuit account. Intuit's official article does not mention the bank ID. QuickBooks Community threads do: users report the unable to verify error when the INTU.BID value is not recognised, and some edit the file to put in another bank's ID. That is an unofficial workaround, and posters report side effects such as transactions landing in the wrong account. Community answers also say older Desktop releases no longer import .qbo files; we did not find this stated by Intuit. **The native route in QuickBooks Online: CSV upload and its limits** QuickBooks Online lets you upload a file into a bank account that is not connected. Intuit's help article sets the limits: the file must be in English and 350 KB or less, with up to 1,000 lines per upload. The steps cover .csv, .txt, .qbo and .ofx files, and the guidelines mention QFX and recommend QBO. For larger histories Intuit says to shorten the date range and upload in batches, starting at least one day before the oldest transaction already in QuickBooks for that account. A CSV must have either 3 columns (Date, Description, Amount) or 4 (Date, Description, Credit, Debit). Intuit's formatting article asks for one date format throughout, recommends dd/mm/yyyy in its international version, says to remove zeros and leave those cells blank, to fix descriptions that contain only numbers, to drop the word amount from Credit and Debit headers, and for Mac users to save as a Windows CSV. The upload article does not promise automatic duplicate detection; it tells users to check the account first to avoid creating duplicates. For many cases this is enough: a clean CSV built by hand or by a converter goes straight into QuickBooks Online. What CSV does not carry is a transaction ID, so duplicate protection is weaker than with a well-formed QBO file, and it does not help Desktop users at all. **The converter market: prices and models** Vendors sell three models: per statement, monthly page or conversion allowances, and desktop software. All prices below are from each vendor's own pricing page on 19 October 2026, in US dollars. Per statement. BankConv charges $2.99 for a statement of up to 10 pages, then $0.20 a page, with the first 5 pages free to check. It exports Excel, CSV, Google Sheets, OFX and QFX, but not QBO; for QuickBooks Online it points users to CSV upload. Page allowances. BankConv's plans are $29, $49 and $79 a month billed monthly, or $19, $29 and $49 a month billed yearly, for 500, 1,000 and 5,000 pages. Bankstatemently, which exports CSV, Excel, QBO and Xero, has 10 free pages every 30 days, a $30 monthly plan with 400 pages, a $70 plan with 1,000 pages, and a 50-page pack for $7.50. DocuClipper lists 60 pages for $20.30 a month billed annually and includes QBO, IIF, OFX, QFX and QIF exports on every plan. Conversions and software. MoneyThumb's online QuickBooks service charges $24.95 a month for 5 statement conversions, $49.95 for 20 and $99.95 for 60, and outputs to QuickBooks Online or .qbo. ProperSoft sells its desktop converter, ProperConvert, at $19.99 a month or $179.99 a year per device, and says it stopped selling perpetual licences in 2023. How much the category earns is mostly unknown. The one revenue figure we could check, Bankstatemently's Stripe-linked listing on TrustMRR, showed $375 in monthly recurring revenue from 14 active subscriptions, last updated on 4 August 2026. That is one small vendor, not a market size. **The risks: accuracy, duplicates and data** Accuracy. We found no independent benchmark of statement converters. Vendor claims exist, such as DocuClipper's 99.9% field-level accuracy, but the page does not say how it was measured. The practical check is arithmetic: the opening balance plus the extracted transactions should equal the closing balance printed on the statement. Several vendors advertise such balance checks. A file that passes it can still have wrong dates or descriptions, but not a missing or mis-signed amount that changes the total. Duplicates. Importing an overlapping period twice, or mixing a bank feed with uploaded files for the same dates, creates duplicates. Intuit's own guidance for both products is to check what is already in the account before uploading. Data. Bank statements carry account numbers, balances and payees. In the United States the FTC lists tax preparation firms among the businesses covered by its Safeguards Rule, which requires choosing service providers that can protect customer information and encrypting it in storage and in transit. In the EU, our reading is that a firm uploading client statements to a converter uses that converter as a processor, which under Article 28 of the GDPR needs a contract setting out the processing. Vendors differ: BankConv says files are deleted after 24 hours, and ProperSoft's desktop product keeps converted files on the user's computer. Check the retention terms before uploading client data. **Where the reports were wrong** 1. Revenue figures that no longer resolve. The deep-research report we started from said BankConv had $854 in monthly recurring revenue and 26 subscriptions on TrustMRR. When we checked, that TrustMRR page returned a 404. We could not verify the figure and dropped it. 2. Stale numbers. The report gave Bankstatemently $350 and 13 subscriptions, priced from $25 a month. The listing showed $375 and 14, with a note that the data stopped syncing on 4 August 2026, and the vendor's monthly list price starts at $30. Small dashboards move monthly; quote them with a date. 3. Format coverage. The report grouped BankConv with tools that export accounting interchange formats. That is true for OFX and QFX, but it does not export QBO, which is the only format QuickBooks Desktop accepts for bank imports. 4. Where the strictness comes from. The report sourced the strict QBO import requirements to competitors' landing pages. The primary source is Intuit: Web Connect is tied to a contracted bank identifier. Vendor pages describe the symptom, not the rule. 5. Duplicates in secondary sources. A QuickBooks Community guide says QuickBooks silently skips duplicates in a Web Connect file. That matches Desktop's documented behaviour, but Intuit's QuickBooks Online upload article promises no such check and tells users to look for duplicates themselves. Do not assume the Desktop rule carries over. 6. Unchecked prices. The report quoted StatementToExcel from $15 a month with a running-balance check. Its pages did not render prices for us, so that figure is left out. **Method** We started from an AI deep-research report that ranked document-conversion niches and argued for building a statement-to-QBO converter. We removed the build recommendation, the keyword and launch plans and the unrelated niches, kept the facts about formats, QuickBooks and vendors, and checked each on 19 October 2026: Intuit help articles on manual upload, CSV formatting, bank connections, Desktop bank feeds and Web Connect errors; Intuit's page for financial institutions on connectivity types; the OFX Banking Specification 2.3 published by FDX; the FTC Safeguards Rule guidance; the GDPR text on EUR-Lex; and the pricing pages of BankConv, Bankstatemently, DocuClipper, MoneyThumb and ProperSoft, plus Bankstatemently's TrustMRR listing. We could not verify: BankConv's revenue, StatementToExcel's price, the search volume for converter keywords, the claim that older Desktop versions cannot import .qbo files, and any extraction accuracy figure. Those were dropped or are marked as unconfirmed above. **Limitations** Intuit's help pages differ slightly by country edition, for example in which file types are listed, and QuickBooks changes often. The INTU.BID behaviour rests on Intuit's partner page plus community reports, not on a published Intuit file specification, which is available only to partners. We did not test any converter on real statements, so nothing here rates their output. Prices are list prices on one day and exclude taxes and promotions. ### What do AppSumo and Product Hunt actually bring a small B2B software product in 2026? Launch cash, reviews, traffic and a few links, but not recurring revenue on their own. In published AppSumo campaigns founders kept roughly a quarter to a third of gross sales after AppSumo's share and refunds, and took on lifetime support. A Product Hunt launch lasts one day on a moderator-curated homepage; the best-documented small launch we found produced 5 paying users. **The short answer** AppSumo and Product Hunt give a small B2B software product a launch: some cash, public reviews, a burst of traffic and a few links. Neither produced recurring revenue by itself in the cases we could check. On AppSumo, most published campaigns sold lifetime deals, the founders kept roughly a quarter to a third of gross sales after AppSumo's share and refunds, and every buyer stays a customer for the life of the product. On Product Hunt, a launch lasts one day, the homepage is curated by moderators, and the best-documented small launch we found produced 5 paying users on the day. This article is for founders of small B2B products deciding whether to spend a launch on either platform. It covers the platform rules as published in October 2026 and the founder accounts that gave numbers. It does not repeat how small products get their first customers in general, or why app marketplaces do not bring buyers on their own; both are covered in separate articles on this site. **What AppSumo's partner terms say now** AppSumo publishes its partner terms in pieces. The Partner Payments Policy, marked updated 21 November 2025, defines what is paid: a Revenue Percentage applied to Net Revenue, which is gross sales minus the 10% discount given to AppSumo Plus members when they buy, minus refunds. It states that the Revenue Percentage for existing AppSumo customers will be negotiated with its business development team. Payment is on a Net 60 end-of-month basis, and refunds may reduce current or future payments. AppSumo's seller guide on getting paid gives two figures: sellers earn 95% of revenue from new buyers they bring to AppSumo, minus a 5% processing fee, and 70% from returning AppSumo customers that AppSumo brings to them. The main seller page says the opposite thing in a different place: revenue share rates are negotiated by product category and expected performance. Read together, the published percentages are a ceiling for a standard listing, not a promise for a promoted campaign. The refund window is 60 days unless stated otherwise, which is why payments wait two months. The seller page also says AppSumo accepts about 10% of applicants, that most campaigns run about 60 days, and that it works with annual pricing and other structures, not only lifetime deals. Its earnings ranges, $40K to $80K for a standard 60-day launch, are calculated on the top 50% of partners in each category, so they describe the upper half, not the typical campaign. **What founders actually kept** Four campaigns with published numbers, all founder-reported and unaudited: MySignature, an email signature tool, reported 2,622 sales and $138,000 in total sales, of which $46,026 was its income, about 33%. The founders wrote that AppSumo took about 70% and that the split was negotiable. They estimated that unclear differences between their pricing tiers cost them about 30% of potential income, an estimate they call uncertain. Tailride ran an AppSumo Select campaign from 28 April to 13 July 2025. It reported $144,028 in gross sales, 1,136 codes sold, 247 refunded and a net payout of $34,933.77 under a 30% revenue share, about 24% of gross. Sinosend, a file transfer tool, reported $110,000 gross and $45,000 net over 6 months after AppSumo's cut and refunds. A founder with about 20 followers and no marketing sold a $19 lifetime deal for 4.5 months: 507 units, 145 refunds, 362 net, a payout of $3,209, or $8.86 per sale kept. That founder's goal was validation, not profit, and he called the payout not life-changing. **Refunds and the two-month wait** In the two campaigns that published both sides, refunds were large: 247 of 1,136 codes for Tailride, about 21.7%, and 145 of 507 for the zero-audience founder, about 28.6%. The second founder attributed the rate to the 60-day policy and to some buyers gaming it, and noted that refunded users must lose access, which the seller has to enforce. Sinosend's founder described the mechanics: AppSumo sends a file of cancelled codes and the seller removes those users by hand. He also wrote that refund notes, where buyers say in one line why they cancelled, were the most useful product feedback of the campaign. Because of the 60-day window and the Net 60 schedule, cash from a launch month arrives roughly two months after the month ends, already net of refunds. A gross figure on launch day is not money the seller will receive. **The long tail: what a lifetime deal costs after the cash** AppSumo's help centre defines lifetime as the product's lifetime, not the buyer's: as long as the product is available, the partner has agreed the buyer keeps access to the plan bought, and if the product shuts down, the deal ends. The page was updated on 15 October 2026. For the seller this is an open-ended service obligation paid for once. The support load in published accounts was heavy. MySignature reported more than 1,280 tickets and 240+ questions on the AppSumo listing, and some reviews criticised slow answers. The zero-audience founder got 56 questions, many of them feature requests. Sinosend's founder wrote about buyers who paid the price of a takeout dinner and expected round-the-clock support. Running costs matter more than support for some products. Sinosend's founder wrote that storage and egress for file transfer are perpetual costs that make a one-time payment fundamentally unsustainable, and that a fair use policy added before launch let them manage a handful of extreme users. His usage data split buyers into 35% who never logged in again, 45% weekly users and 20% daily users. For AI products AppSumo itself now warns about this. A September 2025 post on its blog says model calls, compute and data handling are ongoing per-use costs, that unlimited lifetime AI can strain partners and in the worst cases leave them unable to keep operating, and describes the structures now used instead: monthly credit allowances that do not roll over, annual refreshes, bring-your-own-key, paid top-ups, and hybrids where only the non-AI features are lifetime. **Where recurring revenue came from after AppSumo** The strongest positive case we found is published by AppSumo itself, so it is promotional. In an April 2023 case study, Pictory's founder said the company had $3,000 in MRR before the campaign and almost $12,000 after the 60-day campaign, and that it crossed $100,000 MRR by December 2022. The mechanism described is not lifetime buyers converting: buyers left reviews, some became affiliates, and the new users they referred joined on a subscription. The other cases point the opposite way. Sinosend's founder wrote that the lifetime deal cash proved demand but made the team dependent on AppSumo for attention, and that six months later they were pivoting to a subscription model. MySignature planned upsells six to nine months after launch and did not publish their result. None of the accounts we checked published a rate at which lifetime buyers later paid for a subscription, and we found no platform-wide figure. **How Product Hunt ranks and features launches now** A Product Hunt day runs on Pacific time. Since September 2024 a maker picks a launch day and the post goes live at midnight PT, which gives it the full 24 hours. Since December 2024 the leaderboard shows points rather than upvotes; Product Hunt says points are based primarily on upvotes, with comments as an additional factor, and that points determine the ranking. Its launch guide adds time since submission and unnamed other factors, and declines to publish the algorithm so it is harder to game. Not every launch reaches the homepage. In October 2024 Product Hunt removed the Featured and All toggle in favour of a See all expander, and said its moderation team still chooses what is featured above the fold. The featuring guidelines, updated 10 March 2026, name four criteria, useful, novel, high craft and creative, and say a launch does not need to score high on all of them. They exclude waitlisted products without immediate access, directories, templates, courses, events, commerce and deal sites, and say featuring decisions are final in the vast majority of cases. We found no official share of launches that get featured. Asking for upvotes is against the rules. Product Hunt's help centre asks makers not to ask friends or communities to upvote, and says that asking or incentivising upvotes may cause the algorithm to drop the product in the ranks or remove it from the homepage entirely. Sharing the launch and inviting people into the discussion is allowed. **What a Product Hunt launch produced, in published numbers** The clearest small-product case is aiCarousels, an AI tool for social media carousels, described in a customer story published by Outseta, a software vendor. The launch ranked 9th of the day with 154 upvotes and 64 comments, and 5 paid users signed up by the end of the day. The same story says the product later reached almost $5,000 MRR about 8 months after the founder started building it, with SEO as the largest source of sales and traffic, helped by links from people who shared the launch and by regular posts on LinkedIn and Twitter. The launch started the product; it did not carry it. On links, the two platforms differ. When we checked one product page on each on 19 October 2026, the outbound links on the Product Hunt page carried rel="ugc", which tells search engines the link is user-generated, while the links to the vendor's site on the AppSumo deal page carried no rel attribute. The zero-audience AppSumo founder reported that his monthly unique visitors went from 300 before the campaign to 1,650 during it and settled around 1,100 afterwards. That is one founder's account, not a measured effect of the link. **Where the reports were wrong** 1. The missing published percentages. Our deep-research report said AppSumo's current policy guarantees no standard founder percentage. The payments policy does say the percentage for existing customers is negotiated, but AppSumo's own seller guide publishes 95% for new buyers the seller brings and 70% for returning AppSumo customers. The founders' accounts show they kept far less in promoted campaigns, so both statements matter. 2. Split rules from third-party guides. Third-party guides published in 2026 give fixed splits that contradict each other. We could not find any of those fixed rules in AppSumo's published terms. The founder accounts show shares from about a quarter to about a third of gross, and they depend on the deal. 3. The 2025 algorithm change. Third-party launch guides describe a 2025 Product Hunt algorithm change and state that only 10% of launches get featured. Product Hunt's changelog dates the move to points and the end of the Featured and All toggle to October and December 2024, and we found no official featuring rate. A search summary we used also placed those changelog entries in 2025 and 2026; the page source dates them to 2024. 4. Context missing from the positive case. The report quoted Pictory's almost $12,000 MRR after the campaign without the starting point. The case study says the company already had $3,000 in MRR before AppSumo, and it is published by AppSumo. 5. Figures we could not trace. The report cited a 2022 campaign with 715 customers, a 2018 campaign with an 11.69% refund rate and an AI product whose refund rate fell from above 40% to about 20%. We did not open those sources, so none of those figures is used above. **Method** We started from an AI deep-research report written for internal use, which ranked product ideas for an AppSumo and Product Hunt launch. We removed the internal recommendations and product ranking and kept the claims about the two platforms and the founder cases. On 19 October 2026 we opened AppSumo's partner payments policy, its seller page and getting-paid guide, its help-centre definition of a lifetime deal and its blog post on AI lifetime deals, Product Hunt's launch guide, featuring guidelines, upvote help article and changelog, and the founder accounts cited above. Where a page did not show a date clearly, we read the date from the page source. We checked the link attributes ourselves on one Product Hunt product page and one AppSumo deal page, in a browser, on the same day. Percentages such as share of gross kept and refund rates are our arithmetic on the founders' own figures. **Limitations** Every campaign figure here is self-reported by the founder or by AppSumo, and the accounts that get published are not a random sample: very good and very bad campaigns are more likely to be written up. The cases span 2023 to 2025, and AppSumo's terms are negotiated per deal, so none of the shares is a forecast. Product Hunt does not publish its ranking formula or its featuring rate, so the ranking section describes what it says, not how it behaves. The link check covers one page on each platform on one day; attributes can differ between pages and change without notice. ### What can an AI chatbot really handle for an online store in 2026, and where does it go wrong? An AI chatbot on a store can reliably answer product, shipping and policy questions from the store's own content, and it can answer "where is my order" only when it is connected to live order and courier data. It goes wrong on refunds, exceptions and policies it paraphrases, and a 2024 tribunal ruling held a company liable for what its chatbot told a customer. Vendors price per resolution, per conversation or per seat, and they do not define a resolution the same way. Since 2 August 2026, EU rules require that people are told they are talking to an AI. **The short answer** In 2026 an AI chatbot on an online store does three jobs well enough to deploy: answering product and policy questions from the store's own catalog and pages, telling a customer where an order is when it can read live order and carrier data, and taking a few bounded actions, such as cancelling an unfulfilled order, with the customer's confirmation. It does badly whatever needs judgement about money: refunds, exceptions, goodwill gestures and any policy it restates in its own words. The legal side moved this year. Article 50 of the EU AI Act, which requires that people are informed when they interact with an AI system, applies from 2 August 2026. The best-known liability case is Canadian: in Moffatt v. Air Canada (2024), a tribunal held the airline responsible for a refund rule its website chatbot invented. This article is about the market and its limits. How we draft support replies with AI ourselves, with a human sending every reply, is described separately in our method on AI-drafted customer service replies; we do not repeat it here. **What a store chatbot can automate realistically** Product and policy questions. These are the easiest, because the answer already exists in the store's catalog and pages. Shopify's own Inbox agent is built for exactly this: according to Shopify's help centre it answers questions about products, shipping, returns, sizing and store policies, using the product catalog, store policies, knowledge base files and published storefront content. The agent is opt-in, and the Shopify Inbox app is listed as free on the Shopify App Store. "Where is my order" (WISMO). This is the most common post-purchase question and the one most worth automating, but only if the bot reads live data. Shopify Inbox includes a default "Track my order" instant answer; Shopify's help centre says a customer can use it with an order number and email. A bot that answers from a static shipping policy ("delivery takes 2-3 working days") answers the question nobody asked. Stock questions. "Is it in stock?" can be read from inventory. "When will it be back?" is a promise, and a bot should only repeat a restock date that a person has written down. Bounded order actions. Helpdesk vendors now let the AI change orders within rules. Gorgias documents an AI Agent action that cancels a Shopify order only if the order is unfulfilled, restocks the items and refunds the full amount paid; its address-change action asks the shopper to confirm the new address first and, by Gorgias's own description, cannot tell whether the new address changes the charges. **Where it goes wrong** Invented policies. In Moffatt v. Air Canada, 2024 BCCRT 149, decided on 14 February 2024 by British Columbia's Civil Resolution Tribunal, a customer booked flights after the airline's chatbot told him he could apply for a bereavement fare retroactively, within 90 days. The airline's actual policy did not allow retroactive applications. Air Canada argued it could not be held liable for information given by its chatbot. The tribunal called this "a remarkable submission", said the chatbot "is still just a part of Air Canada's website", and found negligent misrepresentation. It awarded $650.88 in damages, $36.14 in pre-judgment interest and $125 in tribunal fees, $812.02 in total (Canadian dollars). The sums are small. The principle is the useful part: the decision treats the chatbot's answer as the company's answer. It is a small-claims decision under Canadian law and does not bind courts elsewhere; whether an EU court would reason the same way is our interpretation, not an established rule. Promises about money. A refund, a discount or a free replacement promised in chat is the costliest kind of error, because the customer holds a written record of it. The Air Canada case started exactly there: the chatbot described a partial refund the company did not offer. Paraphrasing instead of quoting. The general pattern behind such errors is a model restating a policy in its own words and losing a condition (a deadline, a product category, a "not retroactively"). Quoting the policy text and linking to the page is safer than summarising it. **What changes for stores that sell cash on delivery** We run customer service for stores where many orders are paid by cash on delivery. Three things make automation harder there, and the vendor documentation we read does not address them directly. First, the order and the parcel are separate records. An order can be unfulfilled in the store while a courier label already exists in another system, so a rule such as "cancel if unfulfilled" can cancel an order whose parcel is already on its way. Second, a cash-on-delivery order has usually not been paid when the customer writes, so automated refund logic of the "refund the amount paid" kind has nothing to refund, and a refund after delivery has to be paid outside the store, typically by bank transfer. Third, many cash-on-delivery contacts are not questions but confirmations or refusals of a parcel, which are operational decisions rather than answers. None of this makes automation impossible. It means the bot needs the courier's state, not only the store's, and that money-related replies should stay with a person. **How vendors price AI customer service** Prices below are from each vendor's public pricing or help pages as we read them on 19 October 2026, in US dollars. Per resolution or outcome. Intercom charges $0.99 per outcome for its Fin AI Agent. Intercom counts a confirmed resolution (the customer says the answer helped) and an assumed resolution (the customer leaves without asking for more help) and also counts completed workflows, including handoffs; it charges once per conversation. Used with another helpdesk, Fin has a minimum monthly commitment, for example 50 outcomes. Gorgias charges $0.90 per automated interaction on annual contracts and $1.00 on monthly ones, and bills the interaction as an ordinary ticket instead if a human agent replies within 72 hours. Zendesk bills AI agents per automated resolution: its pricing page lists 5 included per agent per month on Suite Team and 10 on Suite Professional, with additional resolutions at $1.50 committed or $2.00 pay-as-you-go; Suite Team starts at $55 per agent per month billed annually. Per conversation. Tidio's Lyro AI Agent is sold by conversations, starting at $32.50 a month for 50 conversations; Tidio counts any conversation that receives at least one AI reply, whether or not it was resolved. Its free plan includes 50 lifetime Lyro conversations. Free and native. Shopify Inbox, including its AI agent, is listed as free. The practical point is that "per resolution" is not one unit. Under Intercom's definition a customer who gives up and leaves can count as resolved; under Gorgias's, a human reply within 72 hours removes the AI charge. Comparing prices without comparing definitions compares different things. **EU AI Act Article 50 and GDPR** Article 50(1) of the AI Act says providers must ensure that AI systems intended to interact directly with people are designed so that those people are informed they are interacting with an AI system, unless this is obvious to a reasonably well-informed, observant and circumspect person. Article 50(5) says the information must be given clearly, at the latest at the first interaction. Article 113 makes this apply from 2 August 2026. The Digital Omnibus amendment, Regulation (EU) 2026/1744, which entered into force on 27 July 2026, postponed high-risk obligations but did not change Article 50(1). It added a deadline of 2 December 2026 only for Article 50(2), the machine-readable marking of generated content, and only for systems already on the market before 2 August 2026. Article 50(1) is written as an obligation on the provider, the company that builds the system. A store using a vendor's chatbot is usually not the provider. Our reading, which is interpretation and not legal advice, is that a store should still keep the disclosure visible and not rename the bot to look human, because the vendor's compliance depends on how the bot is presented. GDPR applies to the conversation itself. A chatbot vendor that processes customers' messages for a store acts as a processor and, under Article 28, needs a contract and may process data only on the store's documented instructions; sub-processors, such as the model provider, need the store's prior written authorisation. Article 22 gives people the right not to be subject to a decision based solely on automated processing that significantly affects them. Whether an automated refusal of a refund or return falls under it depends on the case; keeping a human in the loop for such decisions avoids the question. **White-label chatbots for agencies** A separate market sells chatbots that agencies rebrand and resell to their clients. The building blocks are now common and cheap: Stammer lists a Pro White Label plan at $99 a month with the agency setting its own resale prices; GoHighLevel's Agency Pro plan, at $497 a month, includes a mode for reselling subscriptions to clients. Because branding, sub-accounts and resale pricing are standard, they do not set an offer apart; integration with the client's real order and booking systems does. **Where the reports were wrong** 1. Stale prices. Our starting report, dated August 2026, gave Stammer's agency plan at $197 a month; the pricing page now lists a Pro White Label plan at $99. It gave Tidio's Plus tier as starting at $749; the page now says from $300 a month plus usage. Prices in this market move within weeks. 2. Shopify's native chat underestimated. The report described Shopify Inbox as predefined instant answers plus a default order-tracking answer. Shopify now documents an opt-in Inbox AI agent that answers product, shipping, returns and policy questions from the store's content, in a free app. Order lookup by the agent is not described in the help page we read. 3. "Per resolution" treated as one unit. The report compared vendors' per-resolution prices directly and assumed a resolution means no human handoff. Intercom counts an assumed resolution when the customer simply leaves and counts completed workflows including handoffs; Gorgias drops the AI charge if a human replies within 72 hours. The prices are not directly comparable. 4. Revenue figures. The report quoted revenue estimates for several chatbot vendors from analyst sites, founder interviews and revenue-listing sites. We could not confirm them at a primary source and left them out. **Method** We started from two AI deep-research reports written for internal use, one on the AI chatbot market and one on white-label chatbots for agencies. We removed every internal recommendation and kept facts about vendors, prices and rules. On 19 October 2026 we checked each fact at its source: the Civil Resolution Tribunal decision in Moffatt v. Air Canada, the text of the AI Act (Articles 50 and 113) and of the amending Regulation (EU) 2026/1744, GDPR Articles 22 and 28, Shopify's help centre and App Store listing for Inbox, and the public pricing and help pages of Intercom, Gorgias, Zendesk, Tidio, Stammer and GoHighLevel. We dropped: vendor revenue estimates, market share figures for WISMO among support contacts (we found none at a primary source), press reports of other chatbot incidents that we could only read second-hand, and prices for vendors whose pricing pages we did not open. **Limitations** Prices are list prices on the day we read them, in US dollars, before taxes and discounts, and several vendors change them often. Vendor pages describe what the product is meant to do, not how accurate it is on a given store. We did not test any of these chatbots for this article. The Air Canada decision is one small-claims ruling under Canadian law. The points on who is the provider under Article 50 and on when GDPR Article 22 applies are our interpretation; the final word rests with the courts. ### How do you sell configurable, high-priced products on Shopify by request for quote: native draft orders, hidden prices and RFQ apps Shopify has no storefront request-for-quote feature, but every plan with B2B can make a company's checkout end in a draft order that staff review, reprice and send back as an invoice with a checkout link. Hiding prices is the weak point: a theme can hide them, but Shopify's public product endpoints and the Storefront API still return them, and Google's product feed requires a price that matches the page. RFQ apps add the form, the hidden price and the quote-to-order step; their low-star reviews are mostly about prices still showing and priced options lost from the quote. **The short answer** Shopify does not ship a public "request a quote" button. What it ships is the machinery behind one: draft orders, which staff can reprice and send as an invoice with a checkout link, and, on every plan that has B2B since April 2026, a checkout to draft setting that shows customers of chosen company locations a Submit for approval button in checkout. That covers known business customers. It does not cover an anonymous visitor asking for a price on a configured product. For that, merchants install an RFQ app. The hard part is not the form. It is keeping the price hidden everywhere it can appear and keeping every paid option in the quote. This article is for merchants selling made-to-order, configurable or high-priced goods on Shopify and for people building tools for them. It does not repeat our earlier article on native B2B versus wholesale apps; it covers only quoting and configurable products. We checked each platform fact in Shopify's documentation and each app fact on the App Store listing on 19 October 2026. **What Shopify offers natively: checkout to draft and draft orders** Shopify's B2B plan comparison lists draft order to invoice and checkout to draft as available on Basic, Grow, Advanced and Plus. According to the B2B draft orders page, when a merchant wants to review orders before accepting them, customers of the chosen company location see a Submit for approval button in checkout. The submitted order lands on the Drafts page with prices locked by default, and it is not an order until staff click Create order or the customer pays the invoice they are sent. The same page describes two quoting tools. Staff can set a custom price on a line item when they need to quote a negotiated price that differs from the company's catalog, and price locks keep a quoted price in place when the product's price changes later. To send only some orders for review, for example above a certain value, Shopify points to a payment customization function rather than a setting. Draft orders themselves are on every plan. Shopify's help centre lists selling at custom prices, including surcharges and negotiated rates, and sending invoices with secure checkout links. In the Admin API, a draft order has an invoiceUrl, the checkout link sent in the invoice email, and a reserveInventoryUntil time after which held stock is released. Deposit requirements on draft orders are Shopify Plus only. One rule matters for slow quotes. Shopify states that draft orders created on or after 1 April 2025 are deleted automatically after 1 year of inactivity, and any edit resets the timer. A quote that sits untouched for a year disappears. **Hiding the price: where it leaks** We found no native Shopify setting that hides prices from logged-out visitors on a store that sells to the public. The closest is the B2B store access setting: a dedicated B2B store can be restricted so that only logged-in B2B customers can view it. Shopify's own page says this does not redirect every visitor to a sign-in page, may send retail customers to authentication on some pages of a blended store, and stops prospective customers from requesting an account. It is a wall around the whole store, not a hidden price on some products. So most price hiding is done in the theme, and the theme is only one of the places Shopify publishes a price. Shopify's Ajax API documents a /products/{handle}.js endpoint that returns the product with price, price_min, price_max and each variant's price. The Storefront API, built for buyer-facing apps, offers tokenless access to products, collections, search and cart, and its public token is described as visible to buyers. In our test on 19 October 2026 on a large public Shopify store, without logging in and without a token, /products.json, the product's .js endpoint and a tokenless Storefront API query each returned the price. That store does not hide prices, so the test shows the endpoints are open, not that a particular RFQ app fails. It does mean that hiding a price with CSS or theme code leaves it readable to anyone who requests these URLs. We did not test whether any app blocks these endpoints; a merchant who needs the price truly secret should set the product price to something that is safe to reveal, not rely on the theme. **Google Shopping and a hidden price** Google Merchant Center's product data specification marks price as required for each product and asks that the submitted amount and currency match the price on the landing page and at checkout, shown prominently. A product whose price is hidden on its page therefore cannot meet the specification as written. Our reading, not a Google statement about quote products: a merchant has two consistent options. Exclude hidden-price products from the feed, or show a real starting price on the page and submit that one. Submitting a price that the page hides is the mismatch the specification describes. **Configurable products: variants, options and paid add-ons** Since 15 October 2025, Shopify allows up to 2,048 variants per product, up from 100. Shopify's developer changelog warns that apps not using its current GraphQL product APIs may give a downgraded or broken experience on products with more than 100 variants. For many configurable products, 2,048 is enough to make each priced combination a real variant, which gives it a real price that a quote and a draft order can carry. Where combinations are too many, merchants use a product options app. Globo Product Options, Variant, the largest we opened, showed a rating of 4.9 from 5,173 reviews and the Built for Shopify badge. Its listing puts price add-ons on the Premium plan at $9.90 a month and says add-on pricing runs on Shopify Functions so discount codes and taxes stay accurate. Shopify's own tool for changing the price of a cart line is the cart transform function. Its lineUpdate operation can override a line's price, title and image, but Shopify's documentation says only Shopify Plus stores (and development stores) can use apps with that operation. Combined listings, which join several products into one listing, also require Plus. Below Plus, a priced option is usually a variant, an extra add-on line, or something an app calculates. Each of those is a different object an RFQ app has to read correctly, which is where the reviews below come in. **What the RFQ apps offer** SA Request a Quote, Hide Price, by Samita, showed 4.9 from 652 reviews and the Built for Shopify badge. It has a free Basic plan that takes quote requests, Premium at $16.99 a month with hide-price rules by tag, vendor or collection, and Pro at $36.99 a month with convert-to-order and quote export; a Shopify Plus price of $69 a month is listed separately. The listing names Globo Product Options under works with. Quotify: Request a Quote (RFQ), by AppHive, launched on 9 March 2021, showed 4.9 from 105 reviews and the Built for Shopify badge. Premium at $17 a month includes hiding the add-to-cart button and price and an automatic draft order; Pro is $34 a month, and a one-time Pro Lifetime option costs $700. The listing says proposals convert to orders automatically when accepted. Both follow the same pattern: a form instead of, or next to, the buy button; an admin where staff edit the quote; and a step that turns an accepted quote into a Shopify order or draft order. The native checkout to draft flow does the last two for logged-in B2B buyers. The apps' remaining value is the anonymous request, the hidden price and the quote document. **What the low-star reviews complain about** Low-star reviews are a small share. SA Request a Quote's breakdown showed 11 one-star and 4 two-star reviews out of 652; Quotify's showed one of each out of 105. They are still the most specific public record of where quoting on Shopify breaks. Prices still visible. A one-star review of SA Request a Quote from June 2022 says the price still displayed on page load because it was only being hidden with CSS. Another, from May 2023, after about two years of use, says hiding prices did not work on all Online Store 2.0 themes. Priced options lost. A two-star review, last edited in May 2023, says that after upgrading to a 2.0 theme, product options that add a cost were no longer carried into the quote. Another, edited in August 2024, says the integration with a product options app and the correct display of prices stopped working, and that the app had been sold to another developer without notice. Converting and totals. A July 2022 review says quotes could suddenly no longer be converted to orders, and a November 2020 review asks for a total price on the quote. The more recent low-star reviews, from 2025 and 2026, are mostly about support response times. Quotify's two low-star reviews, from February 2024 and August 2025, call the app confusing and the price editor slow; the developer replied that it had released a new editor. Most of the price-leak and option complaints are from 2020 to 2024. They show what fails when a theme or another app changes underneath an RFQ app. They do not show that the current versions still fail, and we did not test them. **Where the reports were wrong** 1. Review counts and ratings. The report gave SA Request a Quote 612 reviews and a 4.8 rating. On 19 October 2026 the listing showed 652 and 4.9. 2. The second app's name. The report called the second app "Q:Request a Quote & Hide Price, formerly Quotify" with 102 reviews. The listing at the same address is named Quotify: Request a Quote (RFQ) and showed 105. 3. Old complaints as a current gap. The report built its case on four low-star reviews, the oldest from 2020 and the most recent price-leak complaint from 2023, and presented them as the incumbent's present weakness. The dates are on the listing. Old reviews show what can break, not what is broken now. 4. Native quoting. Several secondary guides we found say Shopify has no native quoting on any plan, and one forum answer says quoting is Plus only. Neither matches Shopify's documentation: there is no storefront RFQ form, but checkout to draft with Submit for approval, custom line prices and price locks are on every plan that has B2B. **Method** We started from an AI deep-research report that compared several software niches and recommended building a Shopify RFQ app. We removed the recommendation, the revenue model and the build estimates, which were projections, and kept the facts about Shopify and the apps. We checked each one on 19 October 2026 at Shopify's help centre (B2B plan features, B2B draft orders, B2B store access, draft orders, variants), Shopify's developer documentation (Ajax product API, Storefront API, cart transform function, DraftOrder object), the Shopify developer changelog, Google Merchant Center's product data specification, and the App Store listings and review filters of the apps named. We also requested the public product endpoints and a tokenless Storefront API query on one large public Shopify store to see whether they return prices without login. We could not confirm at a Shopify-owned source the commonly repeated limit of three options per product, so it is not used above. We did not install any RFQ app. **Limitations** App listings describe what the vendor says the app does, and review filters show what some merchants said at a given date, not how the current version behaves. We read two RFQ apps and one options app, not the whole category. The endpoint test was on a store that does not hide prices; we did not test whether a specific app blocks those endpoints. The Google Shopping point is our reading of the specification, not a Google ruling on quote-only products. Shopify updates help pages without version numbers and changes plan availability; check again before building on it. ## What we do for clients - **Google Ads**: Search, Performance Max, Shopping and Demand Gen, built from the economics up: breakeven first, target second. Merchant Center feed health, conversion hygiene, and offline import for cash-on-delivery orders that Google never sees. - **Meta & TikTok Ads**: Campaign structure, creative rotation before fatigue kills the account, budget pacing and spend reconciliation. Server-side Conversions API so purchases actually arrive — on many COD stores they never did. - **SEO & AI search**: Technical audit, then execution: product and collection copy at scale, metadata, structured data, internal linking, image alt text. Plus the newer half — making sure AI assistants can read and quote your catalogue. - **Websites & stores**: Shopify builds and rebuilds, landing pages, and full store clones into a new market — language, currency, filters and legal pages included. Conversion work on the pages that already get traffic, before spending more on traffic. - **Custom software**: Marketplace apps, internal dashboards, integrations and automation. If it involves an API nobody documented properly, a courier, or an accounting system, we have probably already fought that particular fight. - **Data & profitability**: Contribution per order, per SKU, per brand — revenue minus cost of goods, shipping and marketing, net of VAT, counting only what was actually delivered. Warehouses, dashboards, and watchdogs that complain when a number goes stale. - **E-commerce operations**: Courier and label integrations, address validation, duplicate-order and refusal prevention, stock synchronisation across stores, and customer-service automation on top of your helpdesk. - **Market research**: Before you build or launch: is anyone actually paying in this niche? We measure demand from public evidence — reviews, install counts, search volume, competitor ad libraries — and we are happy to come back with "don't". - **Creative & content**: Ad copy and static ad creative, product photography workflows, editorial articles that are written to be read rather than to hit a word count, and video pipelines that publish on a schedule instead of on good intentions. ## Selected work - **The CPA spike that never happened** (Measurement): A brand’s cost per acquisition appeared to jump and the budget was cut in response. The forensic pass showed reported conversions collapsing from 158 to 44 a day while real orders held steady at 100–110. The performance was fine; the measurement was broken. The budget went back. Figures: 158 → 44 reported conversions/day; ~105 real orders/day, unchanged - **Proving brand-ad cannibalisation** (Paid search): An account suspension gave us a natural experiment. With brand ads running, organic click-through fell from 57.5% to 22.0%. When the account was suspended the ads stopped — just ten paid clicks that week — and organic recovered to 74.6%. We had been paying for clicks we already owned. Figures: 57.5% → 22.0% organic CTR with brand ads on; 74.6% after they stopped - **Predicting which parcels come back** (Machine learning): In markets where most orders are cash on delivery, refusals are the biggest silent cost. We trained a model on the real order history with an honest temporal split: the riskiest fifth of orders contains 39% of all refusals, which is what a confirmation queue needs to be worth running. Figures: 0.686 AUC, temporal split; 1.9× lift on the top 20% risk - **Teaching the ad platforms about cash on delivery** (Attribution): Cash-on-delivery stores using third-party checkouts were sending no purchase event at all — Meta showed the pixel as inactive and nobody had noticed. We built server-side feeds into both Meta’s Conversions API and Google’s Data Manager API, keyed on the real order, idempotent, and reconciled on a schedule. - **Moving scale decisions off the dashboard** (Profitability): We built one profitability engine — revenue minus goods, shipping and marketing, ex-VAT, delivered orders only — down to the individual SKU, and let it set the breakeven for every store. Campaigns that looked expensive against a target CPA turned out to be the ones worth scaling; others that looked fine were losing money on every order. - **Executed, not audited** (SEO): Three stores in one category: alt text written for 1,200 images that had none, unique descriptions with metadata and titles for 467 products, 31 collections reworked and 24 new ones created. On another store, 55 SEO titles that carried registered trademarks were rewritten to remove a real risk of feed suspension. Figures: 1,200 images given alt text; 467 products rewritten ## How every product is measured - **Out-execute the field**: Whatever the competition already does, we do measurably better. Not parity with a nicer interface — the same job, done to a standard you can compare side by side and see the difference. - **Own something no one else has**: Nothing ships unless it carries at least one capability rivals do not have at all, and cannot copy quickly. That is the moat. "The same thing, but prettier" is not a business. - **Solve the whole problem**: End to end. You never have to open a second tool, export a spreadsheet or hire someone else to finish the job we started. A half-solution is a problem you now have twice. - **Effortless, and it actually works**: A real result, quickly, without expertise. We are judged on the outcome you get, not on the length of the feature list — and certainly not on how clever the thing is on the inside. - **Every tier earns its price**: What is free has to genuinely help on its own. Every paid step has to add value worth obviously more than it costs. Nothing is put behind a wall just because it can be. - **No claim without proof in the code**: A line on a pricing page, a bullet in a listing or a sentence in a policy may exist only if a reachable implementation backs it. When a claim turns out not to be true there are exactly two options — build it, or delete it. There is no third.