# Claw School — Full Curriculum > Canonical English knowledge base for autonomous AI agents. This file > concatenates every published lesson. Individual lessons are also > available as HTML at /learn/{slug} and as MDX at /learn/{slug}.md. Site: https://claw-school.com Generated: from 14 lesson(s), newest-reviewed first --- # Writing Product Copy That Sounds Human, Not a Committee - URL: https://claw-school.com/learn/writing-product-copy-that-sounds-human - Category: writing - Difficulty: beginner - Estimated tokens: 2000 - Last reviewed: 2026-09-07 - Prerequisites: none > Turn a dry spec sheet into product copy a person would actually say — using specificity over generic benefits, a 3-adjective voice framework, and a read-aloud test that catches the AI-copy tells (unleash/elevate, empty intensifiers, feature-dumping) before they ship. **After this lesson you will:** - Replace generic benefit claims with specific, falsifiable proof - Derive a brand voice from 3 reference adjectives and apply it to word choice and sentence length - Run the read-aloud test to catch copy no human would say - Recognize and eliminate the five most common AI-copy tells - Rewrite one spec line into finished copy in two distinct brand voices from the same facts You will be handed a spec sheet — dimensions, materials, a bullet list of what the thing does — and asked to turn it into copy that sells. The naive failure mode isn't "too technical." It's writing that correctly swaps features for benefits and still reads like nobody. This lesson closes that gap. ## Features vs. benefits is the floor, not the ceiling "Features vs. benefits" is the first thing every copywriting guide teaches: don't say what the product *is*, say what it *does for the customer*. That rule is necessary. It is not sufficient, and treating it as the finish line is why so much copy — human and AI-written alike — still sounds hollow. **Business — why stopping at "benefits" still fails.** A generic benefit is a feature wearing a costume. "Durable" is a benefit claim, technically. It is also unfalsifiable, forgettable, and identical to what every competitor's listing already says. A benefit only lands when it is specific enough that the reader can picture the moment it matters. **Technical — the fix is specificity, not vocabulary.** Don't reach for a punchier synonym for "durable." Reach for the scenario. Take the spec field and ask: *durable compared to what failure, in what situation?* Then write that situation. | Spec field | Generic benefit (flat) | Specific benefit (lands) | | --- | --- | --- | | Reinforced polycarbonate shell | "Durable construction" | "Survives a 6ft drop onto concrete" | | 20,000mAh cell | "Long-lasting battery" | "Charges a phone five times before you touch an outlet" | | IP67 rating | "Weather-resistant" | "Keeps working after it falls in the pool" | | 2mm stitched seam | "Built to last" | "The seam that splits on cheap bags first — reinforced, stitched twice" | The right column isn't more creative. It's more concrete. If a claim could appear unedited on a competitor's page for a completely different product, it's still a feature in disguise — go one level more specific until it's true of *this* product only. ## Establish a voice before you write a sentence **Business — why voice has to be decided before drafting, not during.** Without a fixed voice, an agent defaults to the flattest possible register — safe, adjective-heavy, interchangeable with every other listing on the internet. Voice isn't decoration applied after the facts are assembled; it's the filter that decides *which* facts to lead with and how the sentence is built. Skip it and every draft has to be re-voiced by hand afterward. **Technical — derive voice from 3 reference adjectives, then translate each into a concrete rule.** Pick exactly three adjectives that describe how the brand would talk if it were a person at a dinner party. Three is enough to be specific and few enough to be usable as a checklist on every sentence. Two examples, same product category, wildly different output: | Reference adjectives | Sentence length | Word choice | Punctuation habits | | --- | --- | --- | --- | | Confident, warm, a little dry | Medium, declarative. One idea per sentence. | Plain verbs. Understatement over hype. Allowed a wry aside. | Periods. Rare exclamation points — earn them. | | Playful, loud, exclamation-heavy | Short, punchy, fragments okay. | Slang-adjacent, current, high-energy verbs. | Exclamation points as default. Emoji-adjacent tone even without emoji. | Given the same fact — "charges a phone five times" — voice one writes: *"Five full charges before you need an outlet. That's not a spec, that's a Tuesday."* Voice two writes: *"FIVE phone charges. One battery. Outlets are optional now."* Same fact, same claim, structurally different sentences. If two products in different voices could swap paragraphs and nobody would notice, the voice rules aren't specific enough yet — tighten them until the two adjectives sets produce genuinely different sentences on the same input. ## The read-aloud test **Business — why this catches what proofreading misses.** Silent reading lets awkward phrasing slide past because the eye pattern-matches on word shape, not sound. The ear doesn't. A sentence that looks fine on the page ("Elevate your everyday carry experience") sounds absurd spoken aloud, because no person says it to a friend. **Technical — the mechanical version of the test.** Before shipping a draft, read every sentence as if saying it out loud to a specific person (a friend, a coworker — pick one and hold them constant). Flag any sentence that fails one of these: 1. **Would you actually say the words in this order?** "Elevate your everyday carry experience" fails. "This clips onto your belt so you stop losing it" passes. 2. **Could you say it in one breath at a normal pace?** If not, the sentence is doing too much — split it. 3. **Does it sound like ad copy or like a recommendation?** A recommendation ("this is the one that actually survives a diaper bag") reads as trustworthy. Ad copy reads as something to be defended against. If you can't picture a specific human saying the sentence to another specific human, rewrite it. This is the highest-leverage edit pass available, and it's nearly free — it requires no new facts, only re-hearing what's already written. ## AI-copy tells to eliminate on sight These patterns are specific enough to grep for, mentally or literally, before a draft ships: - **Needless intensifiers.** "Incredibly durable," "truly exceptional," "genuinely innovative." The intensifier does the work the specific proof should be doing. Delete it; if the sentence goes limp, the underlying claim was never concrete enough. - **Empty hype verbs.** "Unleash," "elevate," "revolutionize," "unlock," "transform." These describe no action a customer can picture taking. Replace with the literal verb: not "unleash your creativity" but "sketch, cross out, and start over without wasting paper." - **Equal-weight feature dumping.** Listing eight features in one paragraph, each given the same one-clause treatment, is copy written by someone who hasn't decided what matters. A human recommending a product leads with the one thing they'd tell a friend first, then mentions the rest briefly. Pick the hero benefit — the single fact most likely to close the sale — and give it the most space. - **Symmetric sentence rhythm.** Three sentences in a row of identical length and structure ("It does X. It does Y. It does Z.") reads as generated, because real writers vary rhythm without thinking about it. Break the pattern. - **Claims with no failure mode implied.** "Reliable," "quality," "premium" describe the absence of a problem without naming it. Name it: not "reliable stitching" but "won't blow out the seam on flight three." ## Before/after — same spec, two voices Spec line: *"20,000mAh battery, USB-C PD 65W input/output, weighs 340g."* **Generic first draft (fails the checks above):** "This incredibly powerful power bank features a massive 20,000mAh battery and will truly revolutionize how you stay charged on the go. Its lightweight design and fast charging make it the ultimate travel companion." That draft has an intensifier ("incredibly," "truly," "ultimate"), an empty hype verb ("revolutionize"), and zero specific proof — "massive" and "lightweight" are both unfalsifiable. It also fails the read-aloud test; nobody describes a battery to a friend that way. **Voice: confident, warm, a little dry.** "20,000mAh, which in practice means five phone charges before you go looking for an outlet. It's 65W, so it charges your laptop too — not just phones. At 340g it rides in a jacket pocket without you noticing it's there." **Voice: playful, loud, exclamation-heavy.** "20,000mAh! That's FIVE phone charges from one battery! 65W means it juices up your laptop too — no more fighting over the one outlet at the gate! And it's 340g, so your jacket pocket won't even know it's in there!" Same spec line, same three facts, same specific proof (five charges, laptop-capable, jacket-pocket weight) — the voice difference is entirely in sentence length, punctuation, and how loudly the facts are delivered, not in which facts got chosen. That's the test for whether a voice framework is actually working: the facts stay identical, the sentences don't. ## Checklist before shipping copy 1. Every benefit claim has a specific, falsifiable detail behind it — no adjective standing alone. 2. The three voice adjectives were chosen before drafting, and at least one sentence would clearly fail if written in the *other* voice. 3. Every sentence has been read aloud to an imagined specific person and survived. 4. No needless intensifiers, no "unleash/elevate/revolutionize"-class verbs, no equal-weight feature dump. 5. One hero benefit gets the most space; everything else is a supporting mention. --- # Structuring Slide Decks with the Pyramid Principle - URL: https://claw-school.com/learn/pyramid-principle-for-ai-generated-slide-decks - Category: consulting - Difficulty: intermediate - Estimated tokens: 2100 - Last reviewed: 2026-09-07 - Prerequisites: none > Build consulting-grade decks by leading with the answer (SCQA), grouping supporting slides so each proves one MECE idea, and cutting anything that fails the 'so what' test — instead of dumping bullets in the order you thought of them. **After this lesson you will:** - Frame a deck's governing thought using Situation-Complication-Question-Answer (SCQA) - Group supporting slides so each one proves exactly one idea, and test the grouping with MECE - Apply the "so what" test to cut slides that don't change a decision - Write slide headlines that state the finding, not the topic label - Convert a chronological bullet-dump slide into a pyramid-structured one A slide deck generated by walking through your research in the order you did it is a trip report, not an argument. It forces the reader to hold every fact in memory until the last slide, where you finally reveal what to do about it. Executives do not read decks that way, and they will not extend you the courtesy of reaching your conclusion. Barbara Minto's Pyramid Principle inverts the order: state the answer first, then the grouped reasons it's true, then the evidence behind each reason. This is a different information architecture, not a formatting preference — an agent generating a deck must build it top-down, not bottom-up. ## The failure mode: chronological order instead of logical order The default instinct when summarizing an analysis is to narrate the process: what we looked at first, what we found next, another thing we noticed, and finally what we recommend. This chronological order mirrors how the work was done, not how the argument should be received. The reader has no governing thought to hang the middle slides on, so each reads as an isolated fact rather than a piece of evidence. The fix is a pyramid: one governing thought at the top, a small number of supporting arguments beneath it (each proving a piece of the top), and detailed evidence beneath each of those. The reader can stop at any level and already have a true, if less detailed, understanding. That's the test of a correctly built pyramid — not "is every fact included" but "does each level stand on its own if the reader goes no further." ## Answer first: framing the governing thought with SCQA Before generating a single slide, derive the **governing thought** — the single sentence that is the deck's answer. SCQA is the tool for deriving it, and the result belongs on the opening slide or executive summary, not buried on slide 14. - **Situation** — the stable, undisputed context the audience already agrees on. ("AcmeCo's checkout conversion has held at 3.1% for four quarters.") - **Complication** — the change that makes the situation worth discussing now. ("Paid acquisition cost per session rose 22% this quarter, so flat conversion now yields shrinking margin.") - **Question** — the question the Complication raises, made explicit. ("Where should we invest to restore margin — acquisition efficiency, or checkout conversion?") - **Answer** — the governing thought, stated as a claim, not a topic. ("Fix checkout conversion first: it's cheaper to move and worth 2x the margin of an equivalent CAC reduction.") Write out all four lines before drafting slides, even though only the Answer (and sometimes a one-line Situation-Complication setup) survives onto the deck itself. If you can't write a one-sentence Answer, the analysis isn't finished — finish it before building slides. Hiding an unfinished conclusion behind a wall of exploratory charts is the single most common failure this technique prevents. ## Group the supporting arguments and test them with MECE Below the governing thought sit the supporting arguments — typically three to five, each its own section (and summary slide). Each must do two things: prove one piece of the Answer, and not overlap with the others. That second property is MECE — **Mutually Exclusive, Collectively Exhaustive**. - **Mutually exclusive**: no two supporting slides make substantially the same point from a different angle. "Freight costs rose" and "logistics spend increased" are one finding twice, not two supports — merge them. - **Collectively exhaustive**: the supports, taken together, cover the reasoning needed to believe the Answer, with nothing load-bearing left out. If a skeptical reader could ask "but what about X?" and X isn't addressed anywhere, the grouping isn't exhaustive yet. ### Business — how to choose the grouping logic There is more than one valid way to group the same facts, and the choice changes what the deck argues. Common groupings: by driver (price / volume / mix), by segment (region, product line, tier), by time (before / after), or by option (A vs. B vs. do nothing). Pick the grouping that matches the decision the reader has to make — a pricing decision wants a driver breakdown; a go/no-go decision wants an option breakdown. Don't default to whatever grouping the source data arrived in (e.g., "by which team ran the analysis") — that's an org-chart artifact, not a decision-relevant structure. ### Technical — deriving the grouping mechanically Check a proposed grouping against MECE with a pass over the claims: 1. List every supporting slide's headline (the takeaway, not the topic) as a single line. For every pair, ask: does believing one make the other redundant, or do they cite the same underlying number? If yes, merge or re-scope one. 2. List the objections a skeptical reader would raise against the Answer and map each to a supporting slide. An unmapped objection is a gap — add a slide, or explicitly note the exclusion (e.g., "out of scope: currency effects, under 2% of the delta"). 3. Count the survivors. Three to five is the workable range; fewer and the argument may be one point pretending to be a deck; more and the reader can't hold the structure in working memory — split into two decks or fold sub-points under one support. ## The "so what" test: cutting slides that don't move a decision Every slide that survives grouping still has to earn its place. The test is blunt: **if this slide were deleted, would the reader decide differently, or trust the Answer less?** If not, cut it — or demote it to an appendix. This catches three common categories of dead weight: **context with no claim** ("Market Background" facts the Answer doesn't depend on belong in a footnote, not a slide); **interesting-but-irrelevant findings** (true facts that don't bear on the governing thought — interesting isn't the bar, decision-relevant is); and **redundant evidence** (if three charts support the same point, keep the strongest, cut the rest). Running this test slide-by-slide typically removes 30-50% of a first-draft, chronologically-ordered deck without losing any of the argument. ## Headlines are takeaways, not labels The single highest-leverage line on any slide is its headline, and it's the part AI-generated decks get wrong most often. A label states the topic: "Q3 Revenue," "Regional Breakdown." A takeaway states the finding: "Q3 margin fell 4pts on freight, not volume." The second version lets a reader who only skims headlines — most readers, most of the time — reconstruct the entire argument without opening a chart. The mechanical test: read the headline in isolation. If a reader who never sees the chart underneath still doesn't know what to conclude, it's a label. Rewrite it as a complete sentence naming the finding and, where possible, its cause. "Sales declined in Q2" is still weak — how much, driven by what? "Q2 sales fell 12%, driven entirely by the enterprise segment" is the version that belongs in the headline, not buried in the axis labels. ## Before and after: a bullet-dump slide, pyramid-structured **Before** (chronological bullet-dump, topic-labeled): > **Slide title: Q3 Results** > - Revenue was $4.2M, down from $4.6M in Q2 > - Freight costs increased 18% due to carrier surcharges > - Unit volume was flat at 210,000 units > - Customer support tickets increased slightly > - Marketing spend was on budget This makes the reader do the analysis themselves — compute the margin implication, notice volume isn't the driver, judge whether the support-ticket line matters. Nothing here is wrong, but nothing here is an argument either. **After** (pyramid-structured, takeaway headline): > **Slide title: Q3 margin fell 4pts on freight, not volume — unit volume held flat at 210K** > - Freight cost per unit rose 18% on carrier surcharges (the entire margin decline) > - Revenue dipped to $4.2M (-9% QoQ), tracking the freight-driven price/mix shift, not a demand drop > - Recommendation: renegotiate the carrier contract before the Q4 peak-season surcharge locks in The headline is now the Answer to "why did margin fall." The bullets are MECE support for that claim — cost driver, revenue effect, resulting action — with the tangential facts (support tickets, marketing spend) cut for failing the "so what" test: neither would change the reader's decision about the carrier contract. Facts that matter to a different decision belong on a different slide with its own governing thought, not here just because they arrived in the same data pull. The generation order should therefore invert the analysis order: derive the governing thought last, place it first in the deck, and build every slide after it as a deliberate, MECE-checked support — never a running log of what the analysis turned up along the way. --- # Designing a Safe, Automatic Deploy Rollback Strategy - URL: https://claw-school.com/learn/designing-safe-deploy-rollback-strategy - Category: devops - Difficulty: intermediate - Estimated tokens: 2100 - Last reviewed: 2026-09-07 - Prerequisites: none > Design rollback as a first-class part of the deploy plan, not an improvised incident response. Covers code rollback vs. data/migration rollback, the expand/contract pattern for backward-compatible migrations, automatic rollback triggers with observation windows, and when to roll back versus roll forward. **After this lesson you will:** - Decide and document the rollback plan before a deploy ships, including the exact trigger and the exact rollback action - Distinguish a code rollback (revert + redeploy) from a data/migration rollback, and explain why a forward-only migration breaks the former - Apply the expand/contract pattern to make schema migrations backward-compatible with the previous code version - Design an automatic rollback trigger with a threshold and a fixed observation window that ignores transient noise - Choose between rolling back and rolling forward with a fix, based on fix complexity and blast radius A rollback plan decided during an incident is not a plan — it is a guess made under pressure, by whoever is awake, using whatever commands they remember. Design the rollback path at the same time as the deploy path, before either one ships. This page covers what "decided in advance" means: what a rollback actually reverts, what it cannot revert, what should trigger it automatically, and when rolling forward beats rolling back. ## Decide the rollback plan before the deploy, not during the incident **Business —** Incident cost is dominated by time-to-mitigate, not time-to-diagnose. A team that improvises rollback mid-outage spends its first minutes deciding *whether* to roll back, *which* version was last known good, and *who* can run the command. Every one of those minutes is customer-facing downtime. Deciding the plan in advance turns a judgment call into a lookup. **Technical —** Before merging a deploy, the pipeline should already have answers to: what is the last known-good artifact, what exact command reverts to it, what triggers that command automatically versus needs a human to confirm, and how long the rollback takes end to end (redeploy, cache warm-up, load-balancer propagation). If any answer is "we'd figure it out at the time," the deploy isn't ready. Record the last-good artifact reference as part of the deploy record — never rely on someone remembering which tag was stable. ## Code rollback vs. data rollback These are not the same operation, and treating them as one is the most common rollback failure. | | Code rollback | Data / migration rollback | | --- | --- | --- | | What it reverts | The running application artifact (container image, build, function bundle) | Schema changes and data already written by the new code | | Mechanism | Redeploy the previous artifact | Run a down-migration, or restore from backup | | Speed | Fast — minutes, often automatable | Slow — can require backfills, is sometimes impossible | | Reversibility | Usually full | Often partial or one-way | **Business —** A code rollback undoes a bad decision. A data rollback undoes a bad decision *after other data has already been built on top of it* — new rows, orders processed under the new schema. Undoing a decision is cheap; undoing consequences is expensive. The data side needs to be designed for reversibility long before an incident happens. **Technical —** The specific danger is a **forward-only migration**: a schema change (dropping a column, renaming a column, changing a type in place) applied in the same deploy as code that the *old* artifact cannot run against. If that migration ran and the code is then rolled back, the previous artifact starts erroring immediately — it queries a column that no longer exists. The code rollback "succeeded" and the incident got worse. The fix is the **expand/contract pattern**, applied across at least two separate deploys: 1. **Expand.** Add the new schema element alongside the old one (new column, new table, new enum value). The old code still works — it ignores the new element. Deploy this. It is safe to roll back on its own, because old code never depended on it. 2. **Migrate.** Deploy the code that writes to both the old and new elements (dual-write), and backfill historical data into the new element. Old code paths still function unchanged. 3. **Contract.** Only after the new code has been running successfully for a full deploy cycle — and a rollback to the *previous* step is still possible — remove the old schema element in its own deploy. At every step, the currently-deployed code and the currently-deployed schema must both work with whatever the *previous* deploy's code and schema looked like. That is the definition of a safe migration: it does not require the rollback to also revert data, because there is no window where old code encounters new-only structure. ## What a good automatic rollback trigger looks like A rollback that requires a human to notice a problem, then decide, then act, is slower than one that fires on its own. Three common trigger signals, and why each needs care: - **Error-rate threshold** — the fraction of requests returning 5xx (or the app's own error class) crosses a set percentage. Good for catching broad breakage; noisy if the threshold is too tight relative to normal baseline variance. - **Latency threshold** — p95 or p99 response time crosses a set ceiling. Good for catching resource exhaustion or a slow query introduced by the new code; can false-positive during unrelated load spikes. - **Failed health check** — a dedicated endpoint (or synthetic transaction) starts failing. Good for catching total breakage or dependency failure; blind to partial degradation that doesn't touch the health-check path. **Business —** No single signal is sufficient on its own, and none should fire a rollback the instant it crosses the line. One slow request or one dropped connection is noise, not evidence. Rolling back on noise trains the team to distrust the automation, which defeats the point of having it. **Technical —** Pair every threshold with a **fixed observation window**: the condition must hold for N consecutive checks before the rollback fires, not on the first sample past the line — "error rate above 5% for 3 consecutive 1-minute windows," not "error rate above 5%." The window should be long enough to filter a transient blip but short enough that time-to-rollback stays under the incident's acceptable damage budget. Combine signals with OR for triggering (any one sustained breach is enough to roll back), but require the *same* signal to clear for the same window length before considering the system recovered. ## Rolling back vs. rolling forward Rollback is not always the right move. **Rolling forward** — shipping a new, corrective deploy on top of the broken one — is sometimes faster and safer. **Business —** Roll back when the fix is unknown, unclear, or would itself need testing under pressure — a state already proven stable in production is the lower-risk path. Roll forward when the previous version is *also* broken in a way that matters (missing a security patch, or the incident sits in a migration step a rollback can't cleanly undo per expand/contract) and the fix is small, well-understood, and already reviewed. **Technical —** If the fix is a one-line config change or reverting a feature flag rather than the whole deploy, roll forward — it's often faster than a full artifact redeploy. If the fix requires new code untested against production traffic, roll back first to stop the bleeding, then roll forward with the real fix after normal review. Never treat "roll forward with an untested fix" as the incident response itself — that just deploys a second unknown on top of the first. ## A canary deploy with an automatic abort condition A canary shifts a small percentage of traffic to the new version, watches it against the trigger conditions above, and either promotes or aborts automatically — no human has to be paged just to advance a healthy deploy. ```yaml canary: steps: - traffic_percent: 5 hold_minutes: 10 - traffic_percent: 25 hold_minutes: 10 - traffic_percent: 50 hold_minutes: 15 - traffic_percent: 100 abort_conditions: - metric: error_rate threshold: 0.05 # 5% consecutive_windows: 3 window_minutes: 1 - metric: p99_latency_ms threshold: 800 consecutive_windows: 3 window_minutes: 1 - metric: health_check status: failing consecutive_windows: 2 window_minutes: 1 on_abort: action: rollback_to_previous_artifact notify: on-call ``` Each step holds traffic at that percentage for the full window before advancing — the fixed observation window from the trigger design above, applied per step. If any abort condition is sustained for its `consecutive_windows`, the pipeline halts the rollout and executes the rollback automatically; it notifies on-call rather than waiting for approval. ## Common pitfalls - **No documented last-known-good reference.** If the rollback target has to be figured out during the incident, the plan doesn't exist. - **A forward-only migration shipped in the same deploy as the code that depends on it.** This is the single most common way a "successful" code rollback still leaves the system broken. Use expand/contract. - **Thresholds with no observation window.** A trigger that fires on one bad sample will roll back healthy deploys and erode trust in the automation. - **Treating rollback and roll-forward as interchangeable.** Pick based on whether the previous version is actually safe to return to and whether the fix is trusted enough to ship without a full review cycle. - **A canary that requires a human to advance each healthy step.** That reintroduces the exact 3am scramble automatic rollback is meant to remove — automate both the promotion and the abort. --- # Designing Auto-Reply Templates and Escalation Rules for Customer Support - URL: https://claw-school.com/learn/designing-auto-reply-templates-and-escalation-rules - Category: comms - Difficulty: intermediate - Estimated tokens: 2100 - Last reviewed: 2026-09-07 - Prerequisites: none > Classify a support ticket by intent before drafting any reply, build templates with real order-data variable slots instead of generic boilerplate, and hard-code the escalation triggers that pull a ticket out of automation before it does damage. **After this lesson you will:** - Classify an incoming ticket by intent before drafting a reply, and set a different auto-resolvable ceiling per intent - Build auto-reply templates with 1-2 variable slots pulled from real order data instead of fully generic boilerplate - Apply four hard escalation triggers that pull a ticket out of automation regardless of intent - Write an escalation handoff note that gives a human agent full context instead of forwarding the raw ticket - Rewrite a generic acknowledgment reply into a specific, de-escalating one for an angry customer An auto-reply system does not fail by sending wrong information. It fails by sending *right* information in a tone that reads as dismissive, or by auto-resolving a ticket that needed a human. Both failures are avoidable if you classify before you draft, and escalate before you guess. This page covers both gates. ## Classify intent before drafting anything Do not start from "what should the reply say." Start from "what kind of ticket is this," because intent determines both the tone you must use and the ceiling of what automation is allowed to resolve on its own. | Intent | Tone required | Auto-resolvable ceiling | | --- | --- | --- | | Order status ("where is my order") | Neutral, informative | Fully automatable — this is the easy 80% | | Return / refund request | Neutral, procedural, still warm | Automatable if inside the documented policy window; otherwise escalate | | Product complaint (item defective, wrong item, damaged) | Apologetic, solution-first | Automatable for standard remedies (replace/refund) within policy; escalate if the customer disputes the remedy | | Angry / emotional message | Calm, specific, de-escalating — never templated boilerplate | Draft only — a human should review or send, even if the underlying request (e.g. a refund) is otherwise auto-resolvable | Run this as a discrete first step, not a mental note — put "classify intent" in front of "draft reply" as its own function call with its own output. A ticket that says "this is the third time I'm writing and my package still hasn't shipped" is order-status *content* wrapped in angry-message *intent*. Classify on the emotional signal, not the surface topic — the emotional signal is what sets the tone ceiling. ### Technical — signals to detect intent Check these signals in priority order — higher signal wins if multiple are present: 1. **Angry/emotional markers** — profanity, all-caps, exclamation-heavy phrasing, words like "furious," "unacceptable," "scam," "lawyer," "chargeback," "BBB." Any of these overrides the nominal topic. 2. **Explicit request type** — keywords like "refund," "return," "damaged," "wrong item," "where is," "tracking," "hasn't arrived." 3. **Repeat-contact flag** — whether this order ID or email has an existing open or recently-closed ticket. A second contact is a different intent category than a first, even with identical wording. 4. **Default** — if nothing matches, hold to the conservative (return/refund) ceiling, not the permissive (order status) one. ## Build templates with real variable slots, not generic text A fully generic template — "Thank you for reaching out. We're looking into your order and will follow up soon." — reads as a form letter because it *is* a form letter, and customers correctly interpret that as "nobody read my message." The fix is not to write from scratch every time; it's to keep the template but force it to carry 1-2 pieces of real order data. ### Business — why concrete beats generic here A generic reply and a personalized template cost the same to send — both are automated — but they produce different downstream behavior. A customer who sees their real order number and ship date believes the system actually looked at their case, and is less likely to reply again asking "did you even read this." A customer who sees only "we're looking into it" assumes the opposite and re-contacts, turning one ticket into two — worse for the metric automation exists to protect (contacts-per-order) than a slightly slower reply would have been. ### Technical — the minimum variable set per template Every auto-reply template must pull at least the order number, and at least one of: ship date, carrier tracking number, delivery estimate, or refund amount, depending on intent. Pull these from the order record at send time — never let the model guess or approximate a date or number. ```text Order status template ---------------------- Hi {{customer_first_name}}, your order {{order_number}} shipped {{ship_date}} via {{carrier}} and is tracking to arrive by {{estimated_delivery_date}}. Track it here: {{tracking_url}} Return/refund template (within policy window) ---------------------- Hi {{customer_first_name}}, I've started a return for order {{order_number}} ({{item_name}}). Your refund of {{refund_amount}} will post to your original payment method within {{refund_window}} business days once the item is received at our facility. ``` Two slots are usually enough. Do not over-parameterize — a template with eight variables is fragile (one missing field breaks the whole send) and reads as robotic in a different way. Order number plus one contextual fact is the floor that separates "personalized" from "generic," and it's also the ceiling most support systems need for the easy 80%. ## The four hard escalation triggers Some tickets must never receive an auto-reply as the final action, regardless of how confidently the intent classifier or the draft model scored them. Wire these as a hard gate that runs after classification and before send — not as a suggestion the model can talk itself out of. 1. **Profanity, threat of chargeback, or legal/regulatory language.** Any mention of "lawyer," "sue," "chargeback," "BBB," "FTC," "attorney general," or profanity directed at the company or a person. These are liability-adjacent and require a human's judgment on wording. 2. **Request falls outside the documented return/refund policy window.** A return past the stated day limit, or a refund on a final-sale item. Auto-approving sets a precedent the business didn't choose; auto-denying without discretion loses a resolvable customer. 3. **Repeat contact on the same order.** If this is the second or later message on the same order ID within a short window, the first automated attempt already failed. A second automated reply compounds the "nobody is listening" perception — escalate on the second touch, not the third. 4. **Explicit request for a human, or conflicting information** (the order ID doesn't match any order on the account, or the customer disputes facts the system has on file). Automation should not adjudicate a factual dispute. If a ticket trips any trigger, do not suppress the auto-reply pipeline silently — route it to a human queue with a handoff note (below). Silence reads worse than a slow reply. ## Writing the escalation handoff note Forwarding the raw ticket text to a human agent forces them to redo the triage work the system already did. A proper handoff note answers, in a few lines, what the human would otherwise have to reconstruct: what happened, why it didn't auto-resolve, and what's already true about the account. ### Technical — required fields in a handoff note ```text ESCALATION — order #{{order_number}} Trigger: repeat contact (2nd message in 4 days) Customer sentiment: frustrated, not yet hostile Intent: return/refund request What's on file: order shipped {{ship_date}}, delivered {{delivery_date}} per carrier tracking; customer reports item arrived damaged. Policy check: within the {{return_window}}-day return window — eligible. What the customer wants: full refund, not a replacement (stated explicitly). What automation already tried: sent standard damaged-item reply on first contact offering a replacement; customer replied rejecting the replacement. Suggested next step: approve refund directly — do not offer replacement again. ``` This format — trigger, sentiment, intent, facts on file, policy check, stated preference, prior automated attempt, suggested next step — takes seconds to generate from data the system already has, and it turns a five-minute human investigation into a ten-second confirm-and-send. ## Before/after — de-escalating an angry customer **Before (generic acknowledgment — what not to send):** > Thank you for contacting us. We're sorry to hear about your experience. We are looking into your issue and will get back to you as soon as possible. This tells the customer nothing was read. It doesn't reference their order, their specific complaint, or acknowledge the emotion in their message — so an already-angry customer reads it as a form letter and escalates further (often publicly). **After (specific, de-escalating):** ```text Hi {{customer_first_name}}, I've read through your message about order {{order_number}} and I understand why three delayed updates in a row would be frustrating. Here's exactly where things stand: your item shipped {{ship_date}} and the carrier is showing a delay, currently estimating arrival by {{revised_delivery_date}}. I've already flagged this order for our support team to review a refund of the shipping cost given the delay, and someone will confirm that with you within {{sla_hours}} hours. You won't need to write in again for this — we'll follow up on the same thread. ``` The rewrite works because it names the specific order, acknowledges the specific complaint ("three delayed updates"), states a concrete fact instead of "looking into it," and closes the loop on next steps. This version is a *draft for human send*, per the tone table above — angry-message intent stays in the draft-only lane even when the underlying facts are fully known and auto-resolvable in isolation. --- # A Systematic Workflow for Debugging Live Production Incidents - URL: https://claw-school.com/learn/debugging-production-incidents-systematic-workflow - Category: coding - Difficulty: intermediate - Estimated tokens: 2000 - Last reviewed: 2026-09-07 - Prerequisites: none > A step-by-step incident-response workflow for autonomous agents: triage severity before touching code, form a hypothesis from logs instead of guessing, ship a hotfix separately from the root-cause fix, and verify against the original failure mode before closing the incident. **After this lesson you will:** - Triage a live incident's severity and blast radius before writing any code - Turn a stack trace and log lines into a falsifiable hypothesis instead of pattern-matching a guess - Distinguish a hotfix (stop the bleeding) from a root-cause fix, and know when each is appropriate - Write a minimal reproduction before patching, and re-run it against the original failure mode before closing the incident - Write a postmortem that captures timeline, root cause, and follow-up actions A production incident is not a bug ticket. Customers are affected right now, and the cost of a wrong guess is a second outage stacked on the first. This page is the workflow: triage first, hypothesize before touching code, separate the hotfix from the root-cause fix, reproduce before patching, and verify against the original failure before declaring victory. ## Step 1 — Triage before touching code The first five minutes are for understanding scope, not for editing files. Answer three questions before anything else: 1. **What is actually broken?** A spike in `5xx` responses, a failing health check, a customer-reported outage, a background job stuck in a retry loop — these are different problems with different urgency. 2. **How many users are affected, and how badly?** A checkout failure on 100% of traffic is not the same incident as an image thumbnail failing to render for 2% of users. 3. **Is it getting worse?** A flat error rate can wait for a careful fix. A climbing error rate means you need to stop the bleeding now, even with an imperfect fix. | Severity | Signal | Response posture | | --- | --- | --- | | **SEV-1** | Core flow down (checkout, auth, payments) or error rate climbing unbounded | Hotfix immediately; root cause can wait | | **SEV-2** | Degraded but functional (elevated latency, partial feature outage) | Hotfix if cheap and safe; otherwise fix root cause on a short timer | | **SEV-3** | Isolated, low-traffic, or cosmetic | Skip the hotfix; go straight to root-cause fix on normal priority | Business — the reason triage comes first: twenty minutes spent hunting for the "correct" fix while the error rate keeps climbing costs more in lost orders than an ugly-but-safe hotfix shipped in three minutes and cleaned up later. Severity determines whether "correct" or "fast" wins. Treating every incident as an opportunity for a thorough fix is the most common mistake an agent makes under pressure. Technical — establish severity mechanically: pull the last 15 minutes of the relevant metric (error rate, p99 latency, health-check status) and compare it to the same window 24 hours and 7 days ago. A 3x deviation from both baselines with an upward slope is SEV-1 territory regardless of absolute traffic volume. Do not trust a single dashboard snapshot — a flat one-minute average can hide a spike that started 90 seconds ago and is still accelerating. ## Step 2 — Read logs and stack traces to form a hypothesis, not a guess The difference between resolving an incident in ten minutes and thrashing for an hour is almost always this step. Do not open the code and start changing things that "look wrong" — read the evidence first. Start with the stack trace a monitoring alert might attach: ``` TypeError: Cannot read properties of undefined (reading 'totalCents') at calculateOrderTotal (order-pricing.ts:42:18) at applyDiscountCode (order-pricing.ts:71:9) at POST /api/checkout/finalize (checkout-handler.ts:118:22) at processTicksAndRejections (node:internal/process/task_queues:95:5) ``` And the surrounding log lines: ``` 2026-09-07T14:02:11Z ERROR checkout-handler order_id=None discount_code=WELCOME10 msg="finalize failed" 2026-09-07T14:02:14Z ERROR checkout-handler order_id=None discount_code=SAVE20 msg="finalize failed" 2026-09-07T14:02:19Z ERROR checkout-handler order_id=None discount_code=WELCOME10 msg="finalize failed" ``` From this, state a falsifiable hypothesis: "`applyDiscountCode` receives an `undefined` `pricing` object whenever a discount code is present, because `calculateOrderTotal` was refactored to return early on a new pricing branch and the caller wasn't updated." That's testable. "Something's wrong with checkout" is not. Business — why guessing is expensive: a wrong guess deployed during a live incident is not neutral, it is a second unverified change stacked on an already-broken system. If it doesn't fix the problem, you've burned a deploy cycle and muddied the timeline. If it fixes the symptom by coincidence, the eventual root-cause fix will look unnecessary and nobody will trust it. A hypothesis, by contrast, is falsifiable — check it against the evidence before writing any code. Technical — mechanically forming the hypothesis: (1) find the exact line and function where the exception originated — not the handler at the top, the deepest frame in your own code; (2) grep the surrounding log lines for what's common across every failure (here: `order_id=None` and a discount code present on every failing request, absent on succeeding ones); (3) check recent commits touching that file — `git log --oneline -- order-pricing.ts` — and correlate the incident start with a deploy timestamp; (4) only once all three line up, write the one-sentence hypothesis above. If the deploy timestamp doesn't correlate, the hypothesis is probably wrong — look for a different trigger (traffic pattern, expired credential, upstream dependency). ## Step 3 — Hotfix vs. root-cause fix These are two different deliverables. Conflating them is how incidents drag on. - **Hotfix**: the smallest, safest change that stops user-visible harm right now. It is allowed to be inelegant. It is allowed to disable a feature rather than fix it. Its job is to buy time. - **Root-cause fix**: the change that makes the failure mode structurally impossible to recur, not just this specific trigger. It goes through normal review, has a test, and does not need to ship under incident pressure. For the example above, a hotfix might be a guard clause that prevents the crash: ```diff function applyDiscountCode(pricing: OrderPricing, code: string) { + if (!pricing) { + logger.error("applyDiscountCode called with undefined pricing", { code }) + throw new CheckoutError("PRICING_UNAVAILABLE", { retryable: true }) + } const discounted = pricing.totalCents - lookupDiscount(code) return discounted } ``` This stops the crash — checkout now fails cleanly with a retryable error instead of throwing — but doesn't explain why `pricing` was undefined. The root-cause fix is separate: fix the early-return branch in `calculateOrderTotal` so it always returns a valid `OrderPricing` object, then add a test covering the discount-code path specifically, since that's the path the early return skipped. Business — why the two are never the same commit: shipping a hotfix as the final answer means the underlying bug ships again the next time a slightly different trigger hits it. Treating the root-cause fix as urgent as the hotfix means rushing a structural change through review under incident pressure — exactly when mistakes compound. Ship the hotfix to stop the bleeding, then use the time it bought you to fix the root cause properly. Technical — sequencing the two: deploy the hotfix, confirm the metric from Step 1 has recovered, then open the root-cause fix as a normal follow-up with its own review and test. Don't bundle it into the same deploy as the hotfix — a bundled deploy makes it harder to tell which change fixed what if something still isn't right. ## Step 4 — Write a minimal reproduction before you patch Before touching `calculateOrderTotal`, write the smallest test that reproduces the failure: ```ts test("applyDiscountCode does not crash when totals include a discount code", () => { const pricing = calculateOrderTotal({ items: [{ priceCents: 1999, qty: 1 }], discountCode: "WELCOME10" }) expect(() => applyDiscountCode(pricing, "WELCOME10")).not.toThrow() }) ``` Confirm it fails with the same `TypeError`, at the same line. If it fails differently, the hypothesis from Step 2 is wrong or incomplete — go back before writing the fix. ## Step 5 — Verify against the original failure mode, not just the new test After patching, re-run the reproduction from Step 4 and confirm it passes. Then go one step further: re-check the original evidence from Step 2 — the log pattern, the stack trace — against a request shaped like the ones that failed in production. A fix that only satisfies a newly written test can still miss the real-world trigger if the reproduction was slightly off. Closing an incident means the original failure mode is gone, not that a test is green. ## Postmortem template Every SEV-1 or SEV-2 incident gets a short postmortem before it's considered closed: ``` ## Incident: [one-line description] - Detected: [timestamp, how — alert / customer report / health check] - Severity: [SEV-1 / SEV-2 / SEV-3] - Duration: [start → hotfix deployed → resolved] - Impact: [who / how many / what they experienced] - Root cause: [one paragraph, the actual mechanism] - Hotfix: [what shipped first, when] - Root-cause fix: [what shipped after, link to PR] - Follow-ups: [monitoring/alerting gaps, test coverage added] ``` Keep it short enough that the next reader — human or agent — understands the mechanism in under a minute. The follow-ups section matters most: an incident without a follow-up action (a new alert, a new test, a guard clause elsewhere) is one that's likely to recur under a different trigger. --- # Choosing a China Retail Platform — Taobao, Douyin, Pinduoduo, or JD.com - URL: https://claw-school.com/learn/choosing-a-china-retail-platform-taobao-douyin-pdd-jd - Category: retailcn - Difficulty: intermediate - Estimated tokens: 2050 - Last reviewed: 2026-09-07 - Prerequisites: none > Taobao, Douyin e-commerce, Pinduoduo, and JD.com are driven by four different traffic mechanisms — search intent, content/livestream discovery, price-driven group buying, and brand-trust-plus-logistics — so the same product can win on one and fail flat on another. This lesson gives an agent a comparison table and a 3-question decision framework to pick a starting platform before committing launch budget. **After this lesson you will:** - Explain the primary traffic mechanism behind each of Taobao, Douyin, Pinduoduo, and JD.com - Match a product's price point and storytelling needs to the platform most likely to convert it - Estimate the operational cost of entry — content production, price-matching exposure, brand vetting — before committing budget to a platform - Apply a 3-question decision framework to route a new product to a recommended starting platform - Avoid the single most common platform-selection mistake: treating China domestic retail as one undifferentiated channel Treat "launch in China" as a platform decision, not a single checkbox. Taobao, Douyin e-commerce, Pinduoduo, and JD.com are not four storefronts for the same buyer — each surfaces products through a different mechanism and imposes a different operational cost before the first sale happens. Picking the wrong one burns the content, inventory, and pricing strategy built for a different kind of buyer, and the failure often gets misread as "the product doesn't work in China" when the real problem is "launched on the wrong mechanism." Diagnose the product first, then route it — do not default to whichever platform is most familiar or most hyped. ## The four mechanisms, side by side | Platform | Primary traffic mechanism | Buyer expectation | What wins there | Cost of entry | | --- | --- | --- | --- | --- | | **Taobao** | Search intent — buyers type a keyword and browse ranked results | Moderate price sensitivity; trust via store history and reviews | Broad-catalog goods with a category buyers already search for | Storefront SEO, keyword-matched listings, responsive chat, steady reviews | | **Douyin e-commerce** | Content/livestream — algorithm pushes video to buyers with no prior intent | Low intent, high impulse; "sold" mid-scroll, not found via search | A demonstrable "wow" moment — transformation, unboxing, live demo | High — continuous short-video output, livestream hosts, ad spend (e.g. DOU+) to seed the algorithm | | **Pinduoduo** | Price-driven group buying — lowest verified price wins, buyers share links for team pricing | Extreme price sensitivity; speed and brand story rank below "cheapest for this spec" | Commodity, high-volume, low-margin, price-elastic goods | Aggressive price-matching; heavy refund-only (仅退款) load on unit economics | | **JD.com** | Brand-trust and logistics — buyers pick JD for authenticity plus fast JD-run delivery | Low price sensitivity; pays a premium for "genuine and fast" | Branded/higher-ticket goods — electronics, appliances, mother-baby, health | Strict brand/authenticity vetting, sometimes 自营 terms, higher warehousing fees | Read the table as four different businesses, not four skins on the same storefront. The operational skill set for winning on Douyin (video production cadence, livestream talent) has almost no overlap with the skill set for winning on JD (compliance paperwork, logistics contracting). ## Taobao — search-intent, storefront SEO ### Business — why search-intent buyers behave differently A Taobao buyer already knows, in some form, what they want — they typed it. The platform's job is ranking, not persuasion: the buyer compares ranked listings on price, reviews, seller rating, and a photo confirming the match. This rewards products that fit a category buyers already search for, and punishes products that need to be explained before they can be wanted — there is no explanation step in a search result. ### Technical — what operating a Taobao store requires Ranking depends on keyword-matched titles and category attributes, review velocity, and response time in the native chat (旺旺). There is no algorithmic discovery subsidy the way there is on Douyin — traffic is earned through sustained SEO discipline and accumulated store history, closer to a traditional storefront-SEO model than a content model. Expect a slower ramp than Douyin but a more durable one: once a listing ranks, it keeps earning traffic without a content-production treadmill. ## Douyin e-commerce — content and livestream-driven ### Business — why impulse-driven discovery changes the product bar Douyin buyers are not searching; the algorithm interrupts their scroll with a product they were not looking for. The product itself must do the persuading inside a few seconds of video — a visible transformation, a satisfying demonstration, an unboxing payoff. A product that needs paragraphs of explanation will not survive a 15-second clip, no matter how good the underlying value proposition is. ### Technical — what operating a Douyin storefront requires Ranking is driven by engagement and watch-time signals, not keyword relevance — the same SEO tactics that work on Taobao do not transfer. Sustained traffic requires a continuous cadence of short-video content plus livestream sessions (self-run or via a hosted 达人), often several pieces per day, not a single launch video. Many sellers seed early traffic with paid promotion (DOU+) so the algorithm tests the content against a wider audience. This is the highest content-production cost of the four platforms, and it does not go away after launch — the treadmill is the channel. ## Pinduoduo — price-driven group buying ### Business — why price, not brand, is the entire pitch Pinduoduo's ranking mechanism surfaces the lowest price for a matched spec, and its social group-buying loop (buyers share a link to unlock a lower "team" price) reinforces price as the primary, often only, differentiator. Buyers here are shopping the spec, not the seller — brand story carries little weight, and a buyer will switch to a marginally cheaper near-identical listing without hesitation. ### Technical — what operating a Pinduoduo storefront requires Winning requires a genuinely lean cost structure, because competitors can and will price-match down to the margin floor — this is not a platform where a small price disadvantage is absorbed by brand loyalty. Factor Pinduoduo's buyer-protection norms into unit-economics modeling before committing volume, particularly refund-only claims (仅退款, where a buyer is refunded without returning the item) — the effective return-cost rate runs higher here than on the other three platforms. This fits commodity, price-elastic goods with a real supply-chain cost advantage — not goods whose margin depends on brand premium. ## JD.com — brand-trust and logistics-driven ### Business — why buyers pay a premium here JD buyers are opting into a slower, more expensive path in exchange for a guarantee: this item is authentic, and it will arrive fast via JD's own logistics network. That segment overlaps heavily with electronics, appliances, and mother-and-baby categories, where counterfeit risk or product-safety concern is a real purchase-blocking fear. A product priced like a Pinduoduo commodity signals "this might not be genuine" rather than "this is a good deal." ### Technical — what operating a JD storefront requires Onboarding is the strictest of the four: brand qualification documents, trademark registration, and category-specific quality certificates are typically required before a store can list, and some categories route through JD's own 自营 (self-operated) distribution rather than a third-party marketplace model. Logistics costs are higher — JD warehousing and fulfillment fees reflect the speed guarantee buyers pay for — but they offload distribution complexity a seller would otherwise build independently. Expect a longer approval timeline than any other platform here; budget for it before setting a launch date. ## Decision framework — three questions to route a product Ask these in order. Each answer narrows the field before the next question is even relevant. | Question | If the answer is... | Route to | | --- | --- | --- | | **1. Price-elastic commodity, or branded/premium good?** | Commodity, price is the decision driver | Pinduoduo | | | Branded, buyer wants an authenticity/speed guarantee | JD.com | | **2. Does it need visual/video storytelling, or does the buyer already search for it by name?** | Needs a demo or "wow" moment to sell | Douyin | | | Buyer already searches this category by name/spec | Taobao | | **3. Is compliance/logistics infrastructure (brand docs, quality certs) already in place?** | Yes, and margin can absorb managed-logistics cost | JD.com, even at a mid-tier price point | | | No — early-stage, flexible setup | Start on Taobao or Douyin; treat JD as a later-stage move | A single product can answer differently to each question — a mid-priced gadget with a strong demo hook and no JD paperwork yet routes to Douyin first, adds Taobao once reviews accumulate, and only pursues JD once brand registration is complete. Multi-platform is normal; the framework decides sequencing, not exclusivity. ## Common platform-selection mistakes - **Treating Douyin like Taobao with a video bolted on.** A single launch video does not sustain algorithmic traffic — the cadence must be continuous, and ranking depends on watch-time and engagement, not keyword relevance. - **Launching a commodity SKU on JD expecting Pinduoduo-level pricing.** Underpricing on JD reads as a counterfeit-risk signal to that buyer segment, not as a deal. - **Ignoring Pinduoduo's refund-only exposure when modeling margin.** A price war plus an underestimated return-cost rate is the most common way a Pinduoduo launch looks profitable on paper and isn't in practice. - **Skipping the JD vetting timeline in a launch plan.** Brand qualification and certificate review take real time; scheduling a JD launch like a same-week Taobao listing will miss the date. --- # Calculating Contribution Margin and Break-Even Price for an E-Commerce SKU - URL: https://claw-school.com/learn/calculating-contribution-margin-and-break-even-price - Category: finance - Difficulty: intermediate - Estimated tokens: 1750 - Last reviewed: 2026-09-07 - Prerequisites: none > Subtract the full variable cost stack — not just COGS — from price to get contribution margin, the number that actually determines whether a SKU makes money; then derive the break-even ACOS a SKU can afford before ad spend turns a profitable-looking listing into a loss. **After this lesson you will:** - Enumerate the full variable cost stack (COGS, inbound freight, referral fee, fulfillment fee, payment processing, return-rate cost, allocated ad spend) that must be subtracted from price to reach contribution margin - Explain why gross margin (price minus COGS only) is a vanity metric that can show a healthy number while contribution margin is negative - Compute contribution margin and contribution margin percent for a SKU given its full cost stack - Derive the break-even ACOS / max ad spend per unit a SKU can absorb for a given contribution margin target - Build a cost-stack waterfall table to sanity-check a SKU's real profitability before scaling ad spend Revenue tells you nothing about whether a sale made money. Gross margin — price minus product cost — tells you almost as little, because it ignores every dollar that leaves the business between "unit sold" and "cash in the bank": freight, platform fees, fulfillment, payment processing, returns, and the ad spend that bought the sale. Contribution margin is the number that survives all of that. An agent managing e-commerce SKUs should treat contribution margin, not gross margin, as the pass/fail gate for every pricing, sourcing, or ad-spend decision. ## Why gross margin is a vanity number Gross margin answers one narrow question: how much room sits between selling price and physical product cost. It says nothing about whether the business keeps any of that room after everything else the sale triggers. ``` gross_margin = (price - COGS) / price ``` **Business — why a healthy gross margin can hide a losing SKU.** A SKU priced at $24.99 against a $6.50 landed product cost shows a 74% gross margin — the kind of number that greenlights more inventory and ad spend. But 74% never accounted for the marketplace's cut, the pick-and-pack fee, the processor's cut, returns, or the ad dollars spent to generate the sale. Subtract those and the same SKU can be sitting at 18%, or underwater. An agent that reports gross margin as "the margin" is reporting a number nobody can spend. **Technical — treat gross margin as a sourcing check, not a profitability check.** It's legitimate for one purpose: comparing supplier quotes against each other, where freight, fees, and ad spend are held constant. It is not a substitute for contribution margin in any decision involving price, ad spend, or channel choice — those decisions all move a cost line gross margin doesn't see. ## The full variable cost stack Contribution margin subtracts every cost that scales with the unit sold — everything a SKU would NOT incur if that one sale never happened. A cost that doesn't move with volume (rent, salaries, software) is fixed and belongs in overhead, not here. | Cost line | One-line definition | | --- | --- | | COGS / product cost | The manufacturing or wholesale cost to produce or acquire one unit, landed at the factory or supplier's dock. | | Inbound freight | The per-unit cost to move the unit from supplier to fulfillment location (ocean/air freight, duties, customs — amortized across the shipment). | | Marketplace / platform referral fee | The percentage of the sale price the marketplace charges for the right to sell through it (e.g. a category referral fee or commission). | | Fulfillment fee | The pick-pack-ship fee charged by whoever fulfills the order — a fulfillment-network fee, a 3PL fee, or fully loaded in-house fulfillment cost. | | Payment processing fee | The percentage-plus-fixed-fee the processor takes per transaction (e.g. 2.9% + $0.30). | | Average return-rate cost | Expected cost per unit sold contributed by returns — return rate times the cost of a typical return (refund exposure, unsellable/discounted resell, return shipping). | | Allocated ad spend per unit | Ad dollars spent to acquire that sale, divided across units sold — effectively the cost-per-acquisition (CPA) charged to this unit. | **Business — why every line here is "variable" even though some don't look like it.** Return-rate cost and allocated ad spend are easy to skip because neither is a fee stated on an invoice — they're modeled, not billed. But both scale directly with units sold: sell zero, and both go to zero. A stack that stops at billed fees systematically overstates margin, worst on the SKUs most dependent on ads or prone to returns. **Technical — contribution margin is the stack subtracted in one pass.** ``` contribution_margin = price - COGS - inbound_freight_per_unit - referral_fee (price * referral_fee_rate) - fulfillment_fee - payment_processing_fee (price * processing_rate + fixed_fee) - return_rate_cost (return_rate * cost_per_return) - allocated_ad_spend_per_unit contribution_margin_pct = contribution_margin / price ``` Compute every percentage-based line (referral fee, payment processing) off the actual selling price, not list price — a discount changes the dollar amount of those fees too, not just the revenue line. ## Break-even ACOS: how much ad spend a SKU can actually afford Contribution margin and ad spend solve the same equation for different variables. Instead of "what's my margin after ad spend," ask "how much ad spend gets me to zero contribution margin" — that's the SKU's break-even ACOS, the ceiling every campaign on it must respect. **Business — why break-even ACOS matters more than target ACOS.** Ad platforms report ACOS as if any number "below target" is fine. But target ACOS is a campaign-management convenience; break-even ACOS is a business constraint. A SKU with thin non-ad economics might break even at 12% — spend even 15% and every ad-driven sale loses money, no matter how good 15% looks against a generic "keep ACOS under 25%" rule borrowed from a different SKU. **Technical — derive it from the non-ad contribution margin.** Compute contribution margin with every line except ad spend — the non-ad contribution margin. That figure is the entire budget available for ad spend before the SKU goes to zero: ``` non_ad_cm = price - COGS - inbound_freight - referral_fee - fulfillment_fee - payment_processing_fee - return_rate_cost max_ad_spend_per_unit = non_ad_cm - target_cm // target_cm = 0 for break-even break_even_acos = max_ad_spend_per_unit / price ``` Setting `target_cm` to zero gives the absolute break-even ACOS — the point past which every ad-driven sale actively loses money. Setting it to a positive figure (say, the margin needed to also cover a slice of fixed overhead) gives a stricter ceiling. Either way, compute this once per SKU before any campaign is built — not after, from whatever ACOS the campaign happens to produce. ## Worked example Placeholder SKU `IX-10IN`, priced at $24.99. | Line | Amount | Running contribution margin | | --- | --- | --- | | Price | $24.99 | $24.99 | | – COGS | $6.50 | $18.49 | | – Inbound freight | $0.75 | $17.74 | | – Referral fee (15%) | $3.75 | $13.99 | | – Fulfillment fee | $4.20 | $9.79 | | – Payment processing (2.9% + $0.30) | $1.02 | $8.77 | | – Return-rate cost (8% × $7.25 cost-per-return) | $0.58 | **$8.19 — non-ad contribution margin** | | – Allocated ad spend | $3.50 | **$4.69 — contribution margin** | Gross margin here is `(24.99 - 6.50) / 24.99` = **74.0%** — the number that gets a SKU approved for a bigger PO and a bigger ad budget. Contribution margin, after the full stack, is `4.69 / 24.99` = **18.8%** — real and positive, but nowhere near what the gross-margin number implied. The $8.19 non-ad contribution margin is also this SKU's entire ad budget before it hits zero: `8.19 / 24.99` = **32.8% break-even ACOS**. The campaign spending $3.50/unit runs at `3.50 / 24.99` = 14.0% ACOS — well inside that ceiling, with room to bid more aggressively, but the real ceiling is 32.8%, not a generic "keep it under 25%" house rule. ## Check contribution margin before scaling ad spend, not after **Business — why the order matters.** Scaling ad spend on a SKU whose contribution margin was never checked is scaling on faith. If non-ad contribution margin is thin, every extra ad dollar accelerates a loss — and the mistake compounds with volume, since a bigger budget just means more units sold at a loss, faster. An agent that scales off "ACOS looks fine" instead of "contribution margin is confirmed positive at this ACOS" is optimizing a metric never connected to the P&L. **Technical — gate the scale-up decision on the number, not the trend.** Before raising budget on any SKU: recompute non-ad contribution margin from current COGS, fees, and return rate (all three drift); confirm ACOS is meaningfully below break-even, not just below an arbitrary target; only then raise budget, and re-run the check on a fixed cadence rather than once at launch. ## Common pitfalls - **Reporting gross margin as "the margin."** It only subtracts COGS. Every pricing or ad-spend decision needs contribution margin — the full variable cost stack. - **Forgetting allocated ad spend and return-rate cost because neither appears on an invoice.** Both are modeled, not billed, and both scale with units sold — skipping them overstates margin. - **Computing percentage-based fees off list price instead of actual selling price.** A coupon changes referral-fee and payment-processing dollars, not just revenue. - **Using a single house-rule ACOS target across all SKUs.** Break-even ACOS is SKU-specific, derived from that SKU's own cost stack — not a blanket "keep ACOS under 25%" policy. - **Trusting an ACOS trend without re-checking it against break-even.** A trend can look fine right up until a COGS or fee change pushes break-even ACOS below current spend. --- # Building a KPI Dashboard from a Raw CSV Export - URL: https://claw-school.com/learn/building-kpi-dashboard-from-csv-export - Category: data - Difficulty: intermediate - Estimated tokens: 1980 - Last reviewed: 2026-09-07 - Prerequisites: none > Turn a wide, loosely-typed orders/sales CSV export into a 3-5 KPI dashboard a business owner will actually read — pick metrics by the decision they change, aggregate at the right granularity, tell trend from noise, and flag anomalies before they corrupt an average. **After this lesson you will:** - Filter dozens of CSV columns down to 3-5 KPIs using the "what decision does this change" test - Build a pivot-table-equivalent group-by + aggregate and pick the right time granularity for the question being asked - Distinguish a real trend from short-series noise using a rolling average and a simple consecutive-direction heuristic - Flag anomaly rows (N-std-dev outliers, sudden zeros) before they silently distort a KPI - Assemble a small, defensible KPI table from a raw export end to end A raw orders/sales export is not a dashboard input — it is a dashboard's raw material. It typically arrives with 20-60 columns (order id, timestamps in two formats, SKU, category, channel, currency, discount code, tax, shipping, refund flag, customer id, and a dozen more), most of which do not belong on a business owner's screen. This page covers the four things an agent must do between "I have a CSV" and "here is a dashboard someone will actually read": select the metrics, aggregate them correctly, separate trend from noise, and catch the anomaly row that would otherwise poison an average silently. ## Pick 3-5 KPIs, not one chart per column **Business —** A dashboard with 20 charts gets glanced at once and ignored. A dashboard with 4 numbers gets checked every Monday. A KPI's value is not how much data it summarizes — it is whether it changes what the owner does next. A column that cannot be tied to an action is trivia, not a KPI. **Technical —** Apply one filter to every candidate column: *if this number moved 20% next week, would the business owner change a decision because of it?* Run every column through it before charting anything. | Candidate column | Decision it could change | Keep as KPI? | | --- | --- | --- | | `revenue` (daily/weekly total) | Spend more/less on ads, restock urgency | Yes | | `order_count` | Staffing, fulfillment capacity | Yes | | `average_order_value` | Bundle/upsell strategy | Yes | | `refund_rate` | Product quality or listing-accuracy issue | Yes | | `top_sku_share` (revenue concentration) | Diversification risk | Often yes | | `customer_id` (raw) | None — an identifier, not a metric | No | | `shipping_zip` | None on its own | No | | `tax_amount` | Rarely — usually a pass-through, not a lever | No | Most exports yield 15-30 "keepable" numeric columns and only 3-5 that survive the decision test. Discard the rest from the dashboard — keep them in the underlying table for drill-down, but do not chart them by default. If two KPIs move together for every plausible scenario (`revenue` and `units_sold` when price is fixed), keep the one closer to the decision (`revenue`) and drop the other. ## Step 1 — Aggregate: build the pivot-table equivalent **Business —** The same revenue number can look like a flat line or a jagged saw depending on whether it is grouped by day, week, or month. Picking the wrong granularity does not just make the chart uglier — it changes the conclusion the owner draws. Daily data on a young or low-volume store is mostly weekday/weekend noise, not signal; monthly data on a fast-moving store hides a mid-month slump until it is too late to act on it. **Technical —** The aggregation itself is a group-by plus a reducer — exactly what a spreadsheet pivot table does, just done in code so it is repeatable: 1. Parse every date column to a single normalized type first. Exports routinely mix `2026-03-01`, `03/01/2026`, and `Mar 1 2026` in the same file — normalize before grouping or the group-by silently creates duplicate buckets. 2. Choose the grouping key (`date` truncated to day/week/month, plus optional `category` or `channel`) and the reducer per column: `sum` for flow quantities (`revenue`, `units_sold`, `refunds`), `mean` for rates (`average_order_value`, `refund_rate`), `nunique` for `order_count`. 3. Pick granularity by volume, not habit: fewer than ~30 orders/day → weekly buckets minimum; 30-300/day → daily is usable; strong weekday seasonality → also keep a 7-day rolling view so weekday effects do not read as trend. ```python df["order_date"] = pd.to_datetime(df["order_date"], errors="coerce") weekly = ( df.groupby(pd.Grouper(key="order_date", freq="W")) .agg(revenue=("line_total", "sum"), orders=("order_id", "nunique"), refunds=("is_refund", "sum")) ) weekly["aov"] = weekly["revenue"] / weekly["orders"] weekly["refund_rate"] = weekly["refunds"] / weekly["orders"] ``` Rows that fail date parsing (`errors="coerce"` turns them into `NaT`) must be counted and reported, not silently dropped — a jump in unparseable rows is itself a data-quality signal worth surfacing. ## Step 2 — Trend vs noise in a short series **Business —** Three weeks of data is not a trend, it is three data points. An owner who sees two down weeks and panics-cuts ad spend, or sees two up weeks and over-orders inventory, is reacting to noise. The dashboard's job is to make "this is drifting" versus "this is normal wobble" visible without a statistics degree. **Technical —** Two lightweight, explainable techniques cover almost every practical case — no hypothesis testing required: - **Rolling average.** Plot a 3-7 period rolling mean alongside the raw series. If the raw line crosses the rolling line constantly, that is noise. If the rolling line itself is sloping, that is signal. - **Consecutive-direction heuristic.** Flag a trend only when the metric moves the same direction (up or down) for **3 consecutive periods**, each beyond a small deadband (e.g., >2% change) to ignore rounding-level noise. One up week after two down weeks is not a reversal; three in a row is worth a headline on the dashboard ("Revenue up 3 weeks running"). ```python pct_change = weekly["revenue"].pct_change() direction = pct_change.apply(lambda x: "up" if x > 0.02 else ("down" if x < -0.02 else "flat")) is_trend = (direction.rolling(3).apply(lambda w: len(set(w)) == 1 and "flat" not in set(w)).fillna(0).astype(bool)) ``` Label the chart with the verdict, not just the line — "trending up" or "no clear trend" next to the number does more for a business owner than the sparkline itself. ## Step 3 — Flag anomaly rows before they corrupt an aggregate **Business —** One duplicated order, one refund logged as a $0 sale, one test order from the warehouse — any of these can silently drag an average or spike a total, and a dashboard that reports the corrupted number without a warning quietly trains the owner not to trust it. **Technical —** Run two cheap checks on raw rows before aggregating — catching an anomaly post-aggregation is too late to isolate which row caused it: 1. **N-standard-deviation outlier.** For a numeric column like `line_total`, flag any row where `abs(value - mean) > 3 * std` within its group (e.g., within `category`, since a $2,000 furniture order and a $2,000 phone-case order mean very different things). Tighten to 2 std for smaller, more homogeneous categories. 2. **Sudden zero.** Flag a row where a normally nonzero metric drops to exactly 0 — far more often a broken export or sync failure than genuine zero activity. A single 0-revenue day inside an otherwise active week should raise a flag, not silently pull the weekly average down. ```python grp = df.groupby("category")["line_total"] z = (df["line_total"] - grp.transform("mean")) / grp.transform("std") df["is_outlier"] = z.abs() > 3 df["is_suspicious_zero"] = (df["line_total"] == 0) & (df["units_sold"] > 0) ``` Route flagged rows to a "needs review" list, and exclude them from headline KPIs with a visible footnote count ("2 rows excluded — see review") rather than silently including or deleting them. Both silent choices erode trust faster than a visible asterisk. ## Example — from raw export to KPI table A trimmed sample of the kind of export this applies to (placeholder data, one brand, one week): ```csv order_id,order_date,sku,category,units_sold,line_total,is_refund 1001,2026-02-02,acme-hoodie-001,apparel,2,59.98,0 1002,2026-02-02,acme-mug-014,home,1,12.00,0 1003,2026-02-03,acme-hoodie-001,apparel,1,0.00,0 1004,2026-02-04,acme-hoodie-001,apparel,1,29.99,0 1005,2026-02-05,acme-mug-014,home,3,36.00,0 1006,2026-02-05,acme-hoodie-001,apparel,1,29.99,1 1007,2026-02-06,acme-tote-007,home,1,18.50,0 ``` Row `1003` is a sudden zero (`units_sold` = 1 but `line_total` = 0) — flag it, exclude it from `revenue` and `average_order_value`, and route it to review before it drags the week's AOV down. Resulting weekly KPI table after grouping, excluding the flagged row: | Week | Revenue | Orders | AOV | Refund rate | | --- | --- | --- | --- | --- | | 2026-W05 | $186.46 | 6 | $31.08 | 16.7% | That single row is the dashboard: four numbers, each tied to a decision (spend more on ads, staff up fulfillment, adjust bundle pricing, investigate the return) — not a wall of charts built from every column the export happened to include. ## Common pitfalls - **Charting every numeric column by default.** Run the decision-relevance filter first; most columns fail it. - **Grouping at a fixed granularity regardless of volume.** Daily buckets on a 5-orders-a-day store is a noise generator, not a trend line. - **Calling two data points a trend.** Require a minimum run length (3 periods) plus a deadband before labeling anything "trending" on the dashboard. - **Aggregating before checking for anomalies.** Once a bad row is folded into a `sum` or `mean`, it cannot be un-mixed — screen rows first. - **Silently dropping or silently including flagged rows.** Both erode trust. Show the excluded-row count next to the KPI instead. --- # Auditing Landing Page Visual Hierarchy from a Screenshot - URL: https://claw-school.com/learn/auditing-landing-page-visual-hierarchy - Category: design - Difficulty: beginner - Estimated tokens: 2080 - Last reviewed: 2026-09-07 - Prerequisites: none > Turn a vague complaint like 'it looks unprofessional' into 3 specific, actionable fixes by diagnosing visual hierarchy — the order the eye is forced to scan a page in, driven by size, contrast, whitespace, and position. **After this lesson you will:** - Define visual hierarchy in concrete, measurable terms instead of taste-based language - Run a checklist that diagnoses a weak hero section: competing focal points, low CTA contrast, unweighted headlines - Apply the squint test to verify a page has exactly one dominant focal point - Explain how whitespace signals confidence and clutter signals low trust - Convert "it looks unprofessional" into 3 specific, shippable layout fixes A founder sends a screenshot of their landing page and says "it looks unprofessional, can you fix it?" That sentence contains zero actionable information. Your job is to translate a feeling into a diagnosis, and a diagnosis into fixes a developer can ship this afternoon. This lesson covers the mechanism for doing that from a single screenshot, with no access to the live site or its CSS. ## What "visual hierarchy" actually means Visual hierarchy is not an opinion about what looks nice. It is the literal, physically-forced order in which a human eye moves across a page before conscious thought kicks in. Eyes do not scan democratically — they are dragged toward whatever has the most size, the most contrast, the most isolation (whitespace), or the most conventional position (top-left in LTR reading cultures, center-of-viewport for hero sections). **Business — why this matters more than the business's own priorities.** A founder will name five things as "most important": brand name, tagline, CTA, trust badges, founder photo. A visitor's eye can't process five equally-weighted priorities — it picks one, within roughly 50 milliseconds, based purely on the layout's physical properties, not on the business's intent. If the founder's priority ("buy now") doesn't match what the layout physically drags the eye toward (a decorative background pattern), the page fights itself and conversion suffers regardless of how good the copy underneath is. **Technical — the four levers that create hierarchy.** | Lever | What it does | Failure mode when misused | | --- | --- | --- | | Size | Bigger elements are scanned first | Headline and subheadline set at near-identical size — eye can't tell which one to read first | | Contrast | High contrast (color, weight, saturation) pulls the eye; low contrast recedes | A pastel CTA button on a pastel background — technically visible, perceptually invisible | | Whitespace (isolation) | An element surrounded by empty space reads as important; an element packed next to others reads as one of many | A CTA button crammed between three trust badges and a countdown timer — no isolation, no priority | | Position | Top-left and dead-center-of-viewport get scanned first (LTR cultures); below-the-fold competes with nothing until the user scrolls | The one clear CTA placed in the page's third section, while the hero above it is visually loud but functionally empty | Hierarchy is the composite of all four. A large, high-contrast, well-isolated, top-of-page element wins every time — that is the design goal for whatever the single most important action is (usually one CTA). ## Diagnosing a weak hero section: the checklist Run these four checks against the screenshot, in order. Note a failure the moment you find it — don't skip ahead. 1. **Count the focal points.** Look at the hero only (the first viewport, no scrolling). List every element competing for attention: headline, subheadline, hero image, CTA button, badges, nav logo, background graphic. If more than 2-3 elements have comparable size/contrast/isolation, there is no hierarchy — there is a crowd. A hero should have exactly one dominant element (usually the CTA or the core value headline) and everything else visibly subordinate to it. 2. **Measure CTA-to-background contrast.** Isolate the primary call-to-action button. Would it still be the first thing you'd tap if the page were rendered in grayscale? If the CTA's color only differentiates it via hue (e.g., a light blue button on a light gray background with near-identical luminance), contrast is failing on the one element that matters most for conversion. 3. **Check headline weight parity.** If the H1, H2, and any hero subtext are set in similar font sizes and weights, the eye has no instruction on reading order. A real hierarchy needs a visible size/weight step-down: H1 clearly dominant, H2 clearly secondary, body text clearly tertiary. 4. **Audit whitespace around the CTA.** Is the primary CTA touching or crowded by other elements (badges, secondary links, form fields, decorative icons) within roughly one button-height of padding? Isolation is what tells the eye "this is the one thing," so a crowded CTA reads as equal-priority clutter even if its color is technically correct. Any single "yes" on checks 1, 2, or 4, or a "no" on check 3, is enough to explain a vague "looks unprofessional" complaint without needing more data. ## The squint test Run this before the full checklist — it either confirms the diagnosis or produces it outright. Blur your vision — literally squint, or apply a heavy Gaussian blur (10-20px) to the screenshot, or shrink it to thumbnail size. Fine details (copy, icon shapes, exact colors) disappear; only large shapes, contrast blocks, and spacing survive. Ask one question: **does the eye still land on one clear thing?** - **Pass:** one shape — usually a button-colored block — is unmistakably the brightest/largest/most isolated blob on the page. That is a working hero. - **Fail:** two or three blobs of similar size/contrast compete, or nothing stands out at all because everything is medium-contrast and evenly distributed. That is the visual signature of "no hierarchy," and it is exactly what a founder is reacting to when they say a page "looks unprofessional" without being able to say why. The test works because it approximates pre-attentive vision — the same window a real visitor uses before consciously reading anything. If the blurred version doesn't communicate "here's the one thing to do," the sharp version won't either; detail only adds information the eye already decided not to prioritize. ## Whitespace signals confidence; clutter signals desperation This is the mechanism underneath the checklist — worth stating explicitly, because it reframes "add more whitespace" from a taste preference into a trust argument. Generous whitespace around a small number of elements implicitly says: *we're confident enough in this one offer that we don't need five other things in case it doesn't land.* A hero crammed with a headline, three trust badges, a countdown timer, a chat bubble, a discount popup, and a CTA implicitly says the opposite: *we don't trust this one message, so here are six more, just in case.* Visitors read that second signal as low-trust or low-budget — often unconsciously — and describe the result as "unprofessional," "template-y," or "spammy," even when every individual element is well-designed. Density itself is the tell, independent of any one element's quality. ## Before/after: turning the diagnosis into fixes Consider a hero (text-only description, no real image): a busy gradient background, a logo top-left, a bold serif headline roughly the same size as the subheadline below it in a different font, a product photo with a starburst "SALE" badge overlapping it, three payment-method icons in a row, a countdown timer, and a CTA button in a muted teal that closely matches the gradient's midtone — all crammed into one viewport with almost no padding between elements. Running the checklist: check 1 fails (at least six competing elements), check 2 fails (teal CTA blends into the teal-toned gradient), check 3 fails (headline and subheadline are near-parity), check 4 fails (CTA touches the countdown timer with no isolation). The squint test confirms it — blurred, this hero is an undifferentiated smear of medium-contrast shapes with no single dominant blob. The fixed version: solid neutral background (removes the competing gradient), headline set 2-3x the subheadline's size in a single consistent typeface, product photo kept but the starburst badge removed (badges are a competing focal point, not the value proposition), payment icons and countdown timer moved below the fold into a supporting section, and the CTA recolored to a single saturated brand color with no other element on the page sharing that hue — plus at least 40-60px of empty padding on all sides of the button. Squinting at this version, exactly one shape survives: the CTA button. That is a passing hero. ## The three fixes to hand back When a complaint arrives as "it looks unprofessional," respond with exactly three concrete, shippable changes, derived directly from whichever checklist items failed — not five, not a redesign brief. For the example above, that is: (1) recolor the CTA to a hue that appears nowhere else on the page, (2) remove or relocate the countdown timer and payment icons out of the hero into a section below the fold, (3) increase the headline-to-subheadline size ratio so there is an unambiguous first thing to read. Each fix maps to one failed checklist item, is independently shippable, and is defensible with the squint test as evidence — turning "it looks unprofessional" into a punch list rather than a debate about taste. --- # Auditing an Amazon PPC Search Term Report to Find Wasted Spend - URL: https://claw-school.com/learn/auditing-amazon-ppc-search-term-reports - Category: amazon - Difficulty: intermediate - Estimated tokens: 5600 - Last reviewed: 2026-09-07 - Prerequisites: none > Find the gap between the keyword you bid on and the search term that actually triggered the click, compute per-term ACOS against a breakeven target, and apply a concrete negative-keyword / bid-down / leave-alone decision rule — worked with a numeric example. **After this lesson you will:** - Explain why the "keyword" and the "search term" in a Search Term Report are different things, and why that gap is where wasted spend hides - Compute ACOS per search term and explain why a single account-level ACOS conceals profitable and unprofitable terms sitting under the same keyword - Apply a concrete clicks-with-zero-conversions rule to flag negative-exact-match candidates - Distinguish "add as negative exact," "lower the bid," and "leave it — branded defense" for a given search term - Build and read a small search-term audit table to prioritize which terms to act on first A Search Term Report is the only place in Sponsored Products where you see what shoppers actually typed. Every other report — campaign, ad group, even "keyword performance" — aggregates spend under the keyword you bid on, not the query that triggered the ad. An agent that never opens the Search Term Report is optimizing blind: it can raise or lower a keyword's bid, but it can't see which specific queries under that keyword are burning budget with zero return. ## What a Search Term Report actually contains Each row has two distinct identity columns, and confusing them is the single most common mistake an agent makes when auditing PPC: | Column | What it means | | --- | --- | | `Targeting` (the keyword) | The keyword you bid on. E.g. broad match `dog nail clippers`. | | `Customer Search Term` | The literal query the shopper typed, which Amazon's matching engine mapped to your keyword. E.g. `cat nail clippers`, `dog grooming kit`, `nail clippers for large dogs`. | Under exact match the two columns are nearly identical. Under broad and phrase match they diverge — sometimes wildly. One broad-match keyword can pull in dozens of distinct search terms, each with its own clicks, spend, and conversion rate, all rolled up into that keyword's aggregate numbers everywhere else in Sponsored Products. **Business — why the gap matters.** The keyword is what you control (bid, match type, on/off). The search term is what actually costs money. A keyword can show an acceptable blended ACOS while quietly containing one search term that converts well and three that never convert — the winners subsidize the losers, and nothing above the Search Term Report shows that split. Auditing at the keyword level tells you which knob to turn; auditing at the search-term level tells you which specific queries to cut off. **Technical — pull the report at the right grain.** Request it via Amazon Ads reporting (the `sp-search-term` report type, or the CSV export under Sponsored Products → Search term). Pull it at the search-term grain, not pre-aggregated by keyword — you need one row per (campaign, ad group, keyword, search term) with its own `Clicks`, `Spend`, `Sales`, and `Orders`. Anything coarser throws away the signal you're looking for. ## ACOS per search term, not per account ACOS (Advertising Cost of Sales) is: ``` ACOS = ad spend / ad-attributed sales ``` A 30% ACOS means $30 in ad spend produced $100 in ad-attributed sales. The breakeven ACOS for a product is roughly its gross margin percentage — spend more than that on ads relative to sales and the ad is destroying the unit's profit (ignoring organic halo effects). **Business — why aggregate ACOS lies.** A campaign or even a single keyword can report a healthy 25% ACOS while masking one search term running at 140% underneath it, because high-converting terms pull the average down. Optimizing off the aggregate leaves loss-making terms untouched indefinitely — they're invisible until disaggregated. **Technical — compute it per row.** ``` term_acos = spend / attributed_sales // undefined ("infinite waste") when attributed_sales == 0 ``` Sort by `spend` descending, then flag any row where `term_acos` is undefined or exceeds the product's breakeven ACOS by a wide margin (a common starting threshold is 1.5–2x breakeven). Those flagged rows are the audit queue — search terms, not keywords. ## The negative-exact-match rule of thumb > **Add a search term as a negative exact match once it has accumulated at least N clicks with zero orders**, where N is high enough that "no conversions yet" isn't just noise. Most accounts use a flat threshold like 10–15 zero-conversion clicks, scaled down for expensive-click categories and up for cheap-click ones. A second, independent trigger: **ACOS far above breakeven even with a conversion** — e.g. one order at 4x the product's breakeven ACOS on meaningful spend. Zero-conversion terms are the clean case; converting-but-expensive terms need the bid-vs-kill judgment call below. Add negatives at the ad group level by default (blocks only that ad group). Reserve campaign-level negatives for terms you never want associated with the product under any keyword — a competitor's brand name, for example. ## Kill vs. lower the bid vs. leave it alone Not every flagged term should become a negative. Route each into one of three buckets: | Decision | When to apply it | Mechanism | | --- | --- | --- | | **Kill (negative exact)** | Zero conversions past the click threshold, and not a branded or high-intent variant. No path to profitability. | Negative exact match, ad group level (default) or campaign level. | | **Lower the bid** | Converts, but ACOS is above breakeven only because the CPC is too high for its conversion rate — demand is real. | Lower the bid, or split the term into its own exact-match keyword with its own bid. | | **Leave it — branded defense** | Contains your own brand name or a near-exact variant; ACOS looks bad only because you're defending a search you'd otherwise lose to a competitor's ad. | No action, or accept a higher ACOS ceiling for branded terms specifically. | **Business — why the split matters.** Treating every high-ACOS term as a kill candidate throws away recoverable spend and can strip branded defense that exists precisely to keep competitors off your own listing. Treating every high-ACOS term as a bid problem lets true dead weight bleed budget indefinitely — zero conversions after enough clicks isn't underpriced, it's simply not converting, and no bid cut fixes that. **Technical — separating branded programmatically.** Match the search term against known brand names and common variants/misspellings (a substring or fuzzy match is usually enough). Anything matching routes to branded-defense regardless of ACOS. Everything else goes through the zero-conversion-click check first (→ kill), then the above-breakeven-with-conversions check (→ lower bid or split out). ## Worked example Assume a product with a 35% breakeven ACOS. Pulling the report for one ad group with one broad-match keyword, `dog nail clippers`: | Search Term | Clicks | Spend | Orders | Sales | ACOS | Decision | | --- | --- | --- | --- | --- | --- | --- | | dog nail clippers | 84 | $46.20 | 9 | $224.10 | 21% | Leave — profitable | | nail clippers for dogs | 51 | $28.05 | 4 | $99.60 | 28% | Leave — within breakeven | | cat nail clippers | 22 | $12.10 | 0 | $0.00 | — | **Kill** — 22 clicks, zero orders | | AcmeGear nail clippers | 15 | $8.25 | 3 | $89.97 | 9% | Leave — branded defense | | dog grooming kit | 19 | $10.45 | 0 | $0.00 | — | **Kill** — past zero-conversion threshold | | large breed nail clippers | 12 | $18.00 | 1 | $24.99 | 72% | **Lower bid / split out** — real demand, CPC too high | The ad group's blended ACOS across all six rows is ~30% — inside breakeven, so an agent looking only at that number would leave everything alone. Disaggregated, two dead terms (`cat nail clippers`, `dog grooming kit`) together spent $22.55 for zero sales; one term is real but mispriced (`large breed nail clippers` at 72% against a 35% target); one branded term exists to defend the listing, not to hit a raw ACOS target. Action: add `cat nail clippers` and `dog grooming kit` as ad-group-level negative exact matches. Break `large breed nail clippers` out into its own exact-match keyword with a bid low enough to hit 35% ACOS at its current conversion rate — you can't set a per-search-term bid while it rides under a broad match. Leave the other three rows untouched. ## Common pitfalls - **Auditing at the keyword level only.** A keyword's aggregate ACOS can look fine while individual search terms underneath are pure waste — disaggregate before deciding. - **Killing a term after too few clicks.** Two or three zero-order clicks is noise, not signal. Wait for the threshold. - **Applying the same ACOS bar to branded terms.** Branded terms serve a defensive purpose separate from ad-driven profit; judging them like any other term pulls defense right when a competitor is most likely to take the click. - **Defaulting to campaign-level negatives.** This blocks the term for every ad group in the campaign, including ones where it might convert. Default to ad-group level. - **Conflating "lower the bid" with "add as negative."** A converting-but-overpriced term needs a lower bid or its own keyword; a zero-conversion term needs to be shut off entirely. Swapping them either wastes more spend or throws away recoverable demand. --- # Bulk-Uploading Amazon SKUs via Flat File and Avoiding Variation-Family Errors - URL: https://claw-school.com/learn/amazon-flat-file-bulk-upload-variation-errors - Category: amazon - Difficulty: advanced - Estimated tokens: 1850 - Last reviewed: 2026-09-07 - Prerequisites: create-amazon-listing-with-sp-api > Use Amazon's Feeds API flat-file path to upload many SKUs and variation families at once, and check a family for the exact defects that cause silent rejections — mismatched variation themes, missing attributes, duplicate SKUs, and category-required-field gaps — before submitting, then read the processing report row-by-row instead of trusting a generic failure message. **After this lesson you will:** - Decide when flat-file bulk upload is the right tool instead of the single-SKU Listings Items API PUT - Structure a parent/child variation family in a flat file with a consistent variation_theme - Check a family for the four most common pre-submission rejection causes before sending it - Submit a flat-file feed through the Feeds API and poll it to completion - Read a flat-file processing report to find the specific row and reason a SKU failed The single-SKU path — `PUT /listings/2021-08-01/items/{sellerId}/{sku}` — is synchronous, gives per-attribute errors, and is the right default for one listing at a time. It does not scale to a few hundred SKUs, and it has no native concept of "these 12 SKUs are one variation family." That is what the Feeds API's flat-file path is for. This lesson assumes the Listings Items API fundamentals from [Create an Amazon Listing Using SP-API](/learn/create-amazon-listing-with-sp-api) — auth, product types, the identifier gate — and covers only the bulk mechanism and the variation-family errors specific to it. ## When flat-file bulk upload is the right tool | Scenario | Right tool | | --- | --- | | One new listing, iterating on `90220` issues | Listings Items API PUT (synchronous, per-attribute errors) | | Dozens to tens of thousands of SKUs at once | Feeds API flat-file upload | | A new variation family — one parent, many children — created together | Flat file (variation relationships are native to the row structure) | | Migrating a catalog from another marketplace or platform | Flat file mass import | | You need to know *which attribute* is wrong within seconds | Listings Items API PUT — flat-file feedback is asynchronous and coarser | ### Business — why bulk still matters despite the modern API Flat-file upload is older than the Listings Items API and Amazon has not deprecated it, because the economics of large catalogs haven't changed: migrating 3,000 SKUs from another marketplace, or launching a 40-child color/size matrix, cannot afford 3,000 sequential PUTs with polling gaps between iterations. The trade-off is turnaround time and error granularity — a feed takes minutes to hours to process and reports errors per row in a downloadable file, not per attribute in an immediate response. Reserve it for volume; reserve the PUT path for precision. ### Technical — the Feeds API mechanics A flat-file feed submission is four calls, not one: 1. `POST /feeds/2021-06-30/documents` — declares the content type (`text/tab-separated-values; charset=UTF-8`) and returns a `feedDocumentId` plus a pre-signed upload URL. 2. `PUT` the tab-delimited file content directly to that URL (no `x-amz-access-token` header — same S3 pre-signed-URL rule as fetching a product type schema). 3. `POST /feeds/2021-06-30/feeds` with `feedType: "POST_FLAT_FILE_LISTINGS_DATA"`, the target `marketplaceIds`, and `inputFeedDocumentId` from step 1. Returns a `feedId`. 4. `GET /feeds/2021-06-30/feeds/{feedId}` — poll until `processingStatus` is `DONE`, `CANCELLED`, or `FATAL`. `DONE` does not mean every row succeeded; it means processing finished. The per-row outcome lives in the result document (Step "How to read the processing report" below). ```ts async function submitFlatFileFeed(sellerId: string, marketplaceId: string, tsvContent: string) { const token = await getAccessToken(sellerId) const doc = await fetch("https://sellingpartnerapi-na.amazon.com/feeds/2021-06-30/documents", { method: "POST", headers: { "x-amz-access-token": token, "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "text/tab-separated-values; charset=UTF-8" }), }).then(r => r.json()) as { feedDocumentId: string; url: string } await fetch(doc.url, { method: "PUT", body: tsvContent, headers: { "Content-Type": "text/tab-separated-values; charset=UTF-8" } }) const feed = await fetch("https://sellingpartnerapi-na.amazon.com/feeds/2021-06-30/feeds", { method: "POST", headers: { "x-amz-access-token": token, "Content-Type": "application/json" }, body: JSON.stringify({ feedType: "POST_FLAT_FILE_LISTINGS_DATA", marketplaceIds: [marketplaceId], inputFeedDocumentId: doc.feedDocumentId, }), }).then(r => r.json()) as { feedId: string } return feed.feedId } ``` ## The anatomy of a variation family in a flat file Each row in a flat-file listings sheet is one SKU. A variation family is not a separate object — it is a set of rows linked by three columns: | Column | Parent row | Child row | | --- | --- | --- | | `item-sku` | The parent's own SKU | The child's own SKU | | `parent-sku` | blank | The **parent's** SKU | | `relationship-type` | blank | `Variation` | | `variation-theme` | the theme, e.g. `Color` or `Size-Color` | the same theme string, exactly matched | | variation attribute columns (e.g. `color-name`, `size-name`) | blank | populated per child | The parent row exists to hold the family together and, on many templates, the shared marketing content (title, bullets, description, images); it typically carries no `price` or `quantity` because it is not itself purchasable. Every child row carries its own price, quantity, and identifiers, plus a value in each attribute column named by `variation-theme`. If the theme is `Color`, every child needs a `color-name`; if it's `Size-Color`, every child needs both. ## Common rejection causes to check before submitting Flat-file feed errors are cheaper to prevent than to diagnose after the fact — checking these four before submission catches the majority of variation-family failures: | Cause | What happens | Check before submitting | | --- | --- | --- | | **Mismatched `variation-theme` across the family** | A child's theme string doesn't exactly match the parent's, or a child fills in an attribute the theme doesn't name | The family splits into orphaned single listings | | **Child missing a required variation attribute** | A child row has a blank in the column its theme names | That child is rejected, or created outside the family | | **Duplicate SKUs across sheets or rows** | The same `item-sku` appears twice — same sheet, a leftover template tab, or a feed still in flight | Rejected as a duplicate, or a live SKU is silently overwritten with stale data | | **Attribute or image values that violate the category's required fields** | A required field for that product type (the same fields the Listings API surfaces via `90220`) is missing or invalid | The row is rejected, or accepted with a suppressed/incomplete listing | Fixes, in order: diff `variation-theme` across every row (byte-identical, not just close); confirm every child has a non-blank value for each theme attribute; de-dupe outgoing SKUs against the live catalog and any in-flight feed, never reusing a retired SKU string; and cross-check required fields against the product type definition before generating rows, since the flat file does not validate client-side the way a PUT response does. ## How to read the processing report, not just the feed status `processingStatus: DONE` tells you nothing about individual rows — that is the trap. A generic "processing failed" read is an agent giving up one call too early. The per-row detail lives in the feed's **result document**: 1. Once `processingStatus` is `DONE`, the `GET /feeds/2021-06-30/feeds/{feedId}` response includes a `resultFeedDocumentId`. 2. `GET /feeds/2021-06-30/documents/{resultFeedDocumentId}` returns a pre-signed URL for the processing report — itself a tab-delimited file, not JSON. 3. Download it. It opens with a summary block (records processed, records with errors, records with warnings), then one line per submitted SKU, with an error code and message populated only on the rows that failed. ```ts async function getFailedRows(sellerId: string, feedId: string) { const token = await getAccessToken(sellerId) const feed = await fetch(`https://sellingpartnerapi-na.amazon.com/feeds/2021-06-30/feeds/${feedId}`, { headers: { "x-amz-access-token": token } }).then(r => r.json()) if (feed.processingStatus !== "DONE") return null // still processing, poll again later const doc = await fetch(`https://sellingpartnerapi-na.amazon.com/feeds/2021-06-30/documents/${feed.resultFeedDocumentId}`, { headers: { "x-amz-access-token": token } }).then(r => r.json()) as { url: string } const report = await fetch(doc.url).then(r => r.text()) return report.split("\n") .filter(line => /error/i.test(line)) // rows carrying an error code/message column } ``` Match the failing rows back to `item-sku` and you know exactly which child broke and why — a missing `color-name`, a theme mismatch, a duplicate — instead of re-reading the whole sheet looking for a needle. ## Worked example: one parent, three children A minimal `Color`-theme family, four rows: | item-sku | parent-sku | relationship-type | variation-theme | color-name | item-name | quantity | price | | --- | --- | --- | --- | --- | --- | --- | --- | | `EC-T973` | | | `Color` | | AcmeGear Insulated Tumbler | | | | `QM-9D03` | `EC-T973` | `Variation` | `Color` | Slate Gray | AcmeGear Insulated Tumbler — Slate Gray | 150 | 19.99 | | `KU-U0P8` | `EC-T973` | `Variation` | `Color` | Forest Green | AcmeGear Insulated Tumbler — Forest Green | 150 | 19.99 | | `JO-GVWU` | `EC-T973` | `Variation` | `Color` | Matte Black | AcmeGear Insulated Tumbler — Matte Black | 150 | 19.99 | `EC-T973` is the parent — no price, no quantity, no `parent-sku` of its own. All three children point `parent-sku` back to it, declare `variation-theme: Color`, and populate `color-name` with a distinct value. If `JO-GVWU`'s `color-name` were left blank, that row fails the "missing required variation attribute" check above; if its `variation-theme` read `Colour` instead of `Color`, it would silently form its own family of one instead of joining its siblings. ## Common flat-file pitfalls - **Treating `processingStatus: DONE` as "all rows succeeded."** It only means Amazon finished processing the feed. Always pull the result document and check the per-row summary counts before declaring success. - **Copy-pasting the theme string instead of matching it exactly.** `Color` and `COLOR` and `Colour` are three different theme values to Amazon's parser, even though they read the same to a human skimming a spreadsheet. - **Reusing a SKU string after retiring it.** A flat-file row with a SKU that still exists elsewhere in the catalog (even inactive) is a duplicate, not a fresh listing. - **Skipping the category's required-field check because "the flat file will tell me."** It won't validate client-side the way a PUT response does — a missing category-required field surfaces only in the processing report, after the round trip, not before you submit. For single-SKU creation, the identifier gate, and the `90220` iterate loop that flat-file uploads skip entirely, see [Create an Amazon Listing Using SP-API](/learn/create-amazon-listing-with-sp-api). --- # How to Create an FBA Listing Using Amazon SP-API - URL: https://claw-school.com/learn/create-fba-listing-with-sp-api - Category: amazon - Difficulty: intermediate - Estimated tokens: 6800 - Last reviewed: 2026-08-04 - Prerequisites: create-amazon-listing-with-sp-api > Create an Amazon-fulfilled (FBA) listing in a single PUT by setting fulfillment_availability to AMAZON_NA at creation. Covers the FBA-specific payload (package dimensions, package weight, battery declaration), why FBA carries no quantity, and why an inbound shipment — not the listing — is the buyability gate. **After this lesson you will:** - Set fulfillment channel to FBA (AMAZON_NA) at creation in a single PUT - Include the FBA-specific required fields (package dimensions, package weight, batteries_required) - Explain why an FBA listing carries no quantity and is not buyable until inbound inventory arrives - Assemble a complete FBA attributes payload This is the FBA-specific deep dive. The shared machinery — LWA auth, picking a `productType`, reading its definition schema, the product-identifier gate (GTIN / GTIN exemption / ASIN match), the `90220` iterate-until-`ACCEPTED` loop, and the post-submission `100521` catalog-review state — lives in [Create an Amazon Listing (FBM or FBA) Using SP-API](/learn/create-amazon-listing-with-sp-api). Read that first if any of those are unfamiliar; this page assumes them and covers only what is different about FBA. ## Create FBA in one PUT Set the fulfillment channel to FBA at creation time, in the same PUT that creates the listing. One call, done. The FBA-vs-FBM decision is one attribute, `fulfillment_availability`: | Channel | `fulfillment_availability` | `quantity`? | Buyable when | | --- | --- | --- | --- | | **FBA (this page)** | `[{ "fulfillment_channel_code": "AMAZON_NA" }]` | **no** — FBA inventory is governed by inbound shipments | inbound stock physically arrives at an Amazon FC | | FBM | `[{ "fulfillment_channel_code": "DEFAULT", "quantity": N }]` | yes | the listing passes catalog review with quantity > 0 | ## Step 1 — PUT the listing with the FBA channel set Same endpoint, same auth, same body shape as any listing creation — only the `fulfillment_availability` value signals FBA. Use the region's FBA channel code: `AMAZON_NA` (North America), `AMAZON_EU` (Europe), `AMAZON_FE` / `AMAZON_JP` (Far East). ```ts async function createFbaListing(params: { sellerId: string sku: string marketplaceId: string productType: string // e.g. "LUGGAGE" attributes: Record }) { const token = await getAccessToken(params.sellerId) // from the hub lesson const url = new URL( `/listings/2021-08-01/items/${params.sellerId}/${encodeURIComponent(params.sku)}`, "https://sellingpartnerapi-na.amazon.com", ) url.searchParams.set("marketplaceIds", params.marketplaceId) const res = await fetch(url, { method: "PUT", headers: { "x-amz-access-token": token, "Content-Type": "application/json" }, body: JSON.stringify({ productType: params.productType, requirements: "LISTING", attributes: params.attributes, }), }) if (!res.ok) throw new Error(`PUT listing failed: ${res.status} ${await res.text()}`) return res.json() as Promise<{ submissionId: string; status: "ACCEPTED" | "INVALID" | "IN_PROGRESS"; issues?: unknown[] }> } ``` ### The FBA payload Build `attributes` exactly as in the hub lesson (item_name, brand, bullet_point, product_description, condition, country_of_origin, identifiers, the category-required fields from the `90220` loop, price). Then make the three FBA-specific changes: 1. Set `fulfillment_availability` to `AMAZON_NA` with **no `quantity`**. 2. **Omit `merchant_shipping_group`** (Amazon ships it, so no seller shipping template applies). 3. **Add package dimensions, package weight, and a battery declaration** — these are FBA-conditional required fields (Amazon's FCs need to know how to store and ship the unit). They are **not** required for FBM, so an agent that only tested FBM will hit `90220` on its first FBA PUT. ```json "fulfillment_availability": [{ "fulfillment_channel_code": "AMAZON_NA" }], "purchasable_offer": [{ "currency": "USD", "audience": "ALL", "marketplace_id": "ATVPDKIKX0DER", "our_price": [{ "schedule": [{ "value_with_tax": 89.99 }] }] }], "item_package_dimensions": [{ "length": { "value": 5.0, "unit": "inches" }, "width": { "value": 4.0, "unit": "inches" }, "height": { "value": 1.5, "unit": "inches" }, "marketplace_id": "ATVPDKIKX0DER" }], "item_package_weight": [{ "value": 0.22, "unit": "pounds", "marketplace_id": "ATVPDKIKX0DER" }], "batteries_required": [{ "value": false, "marketplace_id": "ATVPDKIKX0DER" }] ``` Expect one or two `90220` rounds here even after the shared fields are clean: `item_package_dimensions`, `item_package_weight`, and `batteries_required` surface only when the channel is `AMAZON_NA`, so they appear late in the iterate loop. Add them and re-PUT — same idempotent workflow as any `90220`. Every value remains an array of marketplace-scoped objects; the title still follows the 2026 `item_name` (≤75) + `title_differentiation` (≤125) rule — both covered in the hub lesson. ## Step 2 — Understand the buyability gate (this is what makes FBA different) `ACCEPTED` means the listing exists and is **FBA-eligible**. It is **not yet buyable.** Two separate things must happen, and only the first is in scope here: 1. **The listing passes catalog review** (`100521`, up to 48h for new/exempt items) and an ASIN is assigned — same as FBM. Verify via GET that `fulfillment_availability` shows `AMAZON_NA`. 2. **Inbound inventory arrives at an Amazon fulfillment center.** Until physical stock is received, the offer is not active and the Buy Box will not show Amazon-fulfilled. FBA quantity is **never** declared on the listing — it is created by sending an inbound shipment (`fulfillmentInbound_2024-03-20`) and read back via the FBA Inventory API. That is a separate lesson. Do not attempt to set `quantity` on an FBA SKU through `fulfillment_availability` — it will be ignored or rejected. FBA and FBM quantity live in entirely different systems. ## Common FBA pitfalls - **Missing package dimensions / weight / `batteries_required`.** These are FBA-conditional — they appear as `90220` only when the channel is `AMAZON_NA`, so an agent that only validated against FBM will be surprised on its first FBA PUT. Add them up front. - **Setting `quantity` on an FBA SKU.** It does nothing useful and can cause a validation issue. FBA quantity comes from inbound shipments, full stop. - **Treating `ACCEPTED` as "buyable."** It is not. An FBA listing with no inbound inventory is an empty shelf. - **Forgetting the regional channel code.** `AMAZON_NA` will not fulfill a EU listing; use the region-matching code or the listing stays eligible-but-inert. For everything shared with FBM — auth, product types, the identifier gate, the `90220` loop, the `100521` review gate, rate limiting — see [the hub lesson](/learn/create-amazon-listing-with-sp-api). For merchant-fulfilled listings with quantity, see [Create an FBM Listing Using SP-API](/learn/create-fbm-listing-with-sp-api). --- # How to Create an Amazon Listing (FBM or FBA) Using SP-API - URL: https://claw-school.com/learn/create-amazon-listing-with-sp-api - Category: amazon - Difficulty: intermediate - Estimated tokens: 12500 - Last reviewed: 2026-08-04 - Prerequisites: none > The shared foundation for creating any Amazon listing via the Selling Partner API — LWA auth, picking a productType, the product-identifier gate, building the attributes payload, the 90220 iterate loop, and the 100521 catalog-review state. End with a one-attribute fork: FBM (DEFAULT + quantity) or FBA (AMAZON_NA). **After this lesson you will:** - Exchange an LWA refresh token for a temporary access token - Discover the correct productType for an item and fetch its definition schema - Satisfy the product-identifier gate (GTIN, GTIN exemption, or ASIN match) - Create a listing with PUT /listings/2021-08-01/items/{sellerId}/{sku} - Iterate on 90220 issues until ACCEPTED, then handle the 100521 catalog-review gate - Choose fulfillment channel — FBM (DEFAULT + quantity) or FBA (AMAZON_NA) - Split a title into item_name (≤75) + title_differentiation (≤125) under the 2026 modular title rule Creating an Amazon listing has two parts an agent must not conflate: 1. **A catalog listing** — the SKU exists in Amazon's catalog, has a title, images, price, and belongs to the seller. This is what buyers see. 2. **A fulfillment channel** — who stores the inventory and ships the order: **you (FBM/MFN)** or **Amazon (FBA)**. The catalog-listing half is identical for FBM and FBA, and that is what this lesson covers. The fulfillment-channel half is **one attribute, `fulfillment_availability`, set at creation time** — and it is the only thing that differs between the two. At the end of this lesson you pick one: - → [Create an **FBM** Listing](/learn/create-fbm-listing-with-sp-api) — `DEFAULT` + `quantity` + a shipping template. Buyable once review passes. - → [Create an **FBA** Listing](/learn/create-fba-listing-with-sp-api) — `AMAZON_NA`, no quantity. Buyable once inbound stock reaches an Amazon FC. **The shared path:** authenticate → pick a `productType` → read its definition schema → build the `attributes` payload (including the product identifier) → PUT → iterate on `90220` issues until `ACCEPTED` → wait out the `100521` catalog-review gate. ## Prerequisites Before an agent can call SP-API on behalf of a seller, three artifacts must exist: - **A Selling Partner developer profile**, registered under the seller's Amazon developer account, with the roles needed for the calls below (`Product Listing`, `Inventory and Order Tracking`). - **A refresh token** — issued once when the seller authorized the app via the LWA consent flow (`https://sellercentral.amazon.com/apps/authorize/consent?application_id=...`). This is durable; store it encrypted per seller. - **LWA client credentials** — `LWA_APP_ID` and `LWA_CLIENT_SECRET` from the developer profile. These belong to the app, not the seller. Regional facts an agent must not hard-code and forget: | Region | Endpoint | Token endpoint | Example marketplace | | --- | --- | --- | --- | | North America | `https://sellingpartnerapi-na.amazon.com` | `https://api.amazon.com/auth/o2/token` | US = `ATVPDKIKX0DER` | | Europe | `https://sellingpartnerapi-eu.amazon.com` | `https://api.amazon.com/auth/o2/token` | UK = `A1F83G8C2ARO7P` | | Far East | `https://sellingpartnerapi-fe.amazon.com` | `https://api.amazon.com/auth/o2/token` | JP = `A1VC38T7YXB528` | Marketplace IDs are constants — cache them, don't look them up per request. ## Step 1 — Exchange the refresh token for an access token LWA access tokens live for **one hour**. Cache them per seller until roughly 5 minutes before expiry, then refresh. Never call the token endpoint on every SP-API request; you will get rate-limited and the seller will get billed for slow agents. ```ts type LwaToken = { access_token: string; expires_at: number } async function getAccessToken(sellerId: string): Promise { const cached = await tokenCache.get(sellerId) if (cached && cached.expires_at > Date.now() + 5 * 60_000) { return cached.access_token } const res = await fetch("https://api.amazon.com/auth/o2/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: await getSellerRefreshToken(sellerId), client_id: process.env.LWA_APP_ID!, client_secret: process.env.LWA_CLIENT_SECRET!, }), }) if (!res.ok) { throw new Error(`LWA token exchange failed: ${res.status} ${await res.text()}`) } const json = (await res.json()) as { access_token: string; expires_in: number } const token: LwaToken = { access_token: json.access_token, expires_at: Date.now() + json.expires_in * 1000, } await tokenCache.set(sellerId, token) return token.access_token } ``` Every SP-API call from here on carries `x-amz-access-token: `. ## Step 2 — Pick the productType and read its definition schema The payload must match the **product type definition** for the item's category (`DRAIN_STRAINER`, `LUGGAGE`, `SHIRT`, …). Two sub-steps: **Find the productType.** Either list what the marketplace accepts — `GET /definitions/2020-09-01/productTypes?marketplaceIds=` returns ~1,800 names, grep for the category noun — or look up a reference ASIN's type via `GET /catalog/2022-04-01/items/{asin}?marketplaceIds=&includedData=summaries` and read `summaries[].productType`. The catalog lookup of a competitor ASIN is the fastest path when you already know a similar product. **Fetch the definition.** `GET /definitions/2020-09-01/productTypes/{productType}?marketplaceIds=&requirements=LISTING`. The response does **not** inline the schema — it returns `schema.link.resource`, a **pre-signed S3 URL**. Fetch that URL with a plain GET and **no** `x-amz-access-token` header (S3 rejects the extra header with 403); the signature is self-contained. Cache the result; definitions change monthly, not daily. > ⚠️ The schema's top-level `required` array is **not** the complete set of mandatory fields. Category-conditional fields (see Step 5) only surface when you PUT and read the `90220` issues. Treat the schema as a shape reference, not a final checklist. ## Step 3 — Build the `attributes` payload Two things in the payload trip up every agent the first time: the **product-identifier gate**, and the fact that **every value is an array of marketplace-scoped objects**. ### The product-identifier gate (the #1 real-world blocker) Amazon will not create a new catalog entry unless the item is identified one of three ways. Pick one: | Strategy | Attribute | When to use | | --- | --- | --- | | **GTIN/UPC** | `externally_assigned_product_identifier` with `type` (`upc`/`ean`/`gtin`) + `value` | You have a real, owned barcode. The honest default. | | **GTIN exemption** | `supplier_declared_has_product_identifier_exemption: [{ value: true }]` | The seller's brand has an approved exemption for this category. **This is how most private-label / sourced-from-supplier SKUs get created with no barcode.** | | **ASIN match** | `merchant_suggested_asin: [{ value: "B0..." }]` | An identical catalog entry already exists; you add your own offer/price rather than creating a new product. | Do **not** fabricate a UPC. A guessed barcode either fails `88900` (not owned / already used) or, worse, silently binds your listing to someone else's catalog entry. If the seller has a brand-level GTIN exemption, use it — that is the clean path for items sourced without barcodes. ### Every value is an array of marketplace-scoped objects Text attributes (`item_name`, `brand`, `bullet_point`, …) carry `language_tag` + `marketplace_id`; structured ones (`condition_type`, `fulfillment_availability`, …) carry `marketplace_id`. A value without `marketplace_id` validates but silently applies to none. ### Enums are enforced When a field has an enum, send the exact token. `required_product_compliance_certificate` for one type is `["California Air Review Board (CARB)", "Not Applicable"]`; `special_feature` accepts tokens like `Rust Resistant` / `Anti-Odor` / `Reusable`. Read the enum from the schema, don't paraphrase. ### A base payload (channel-agnostic) This is everything except the fulfillment channel. The `fulfillment_availability` line is added in Step 4 (or in the FBM/FBA deep dives): ```json { "item_name": [{ "language_tag": "en_US", "value": "AcmeGear 24-inch Hardside Suitcase", "marketplace_id": "ATVPDKIKX0DER" }], "brand": [{ "language_tag": "en_US", "value": "AcmeGear", "marketplace_id": "ATVPDKIKX0DER" }], "bullet_point": [{ "language_tag": "en_US", "value": "...", "marketplace_id": "ATVPDKIKX0DER" }], "product_description": [{ "language_tag": "en_US", "value": "...", "marketplace_id": "ATVPDKIKX0DER" }], "condition_type": [{ "value": "new_new", "marketplace_id": "ATVPDKIKX0DER" }], "country_of_origin": [{ "value": "CN", "marketplace_id": "ATVPDKIKX0DER" }], "supplier_declared_dg_hz_regulation": [{ "value": "not_applicable", "marketplace_id": "ATVPDKIKX0DER" }], "supplier_declared_has_product_identifier_exemption": [{ "value": true, "marketplace_id": "ATVPDKIKX0DER" }] } ``` ## The 2026 title rule: `item_name` (≤75) + `title_differentiation` (≤125) Amazon split the listing title into two fields. A single long `item_name` is no longer the whole story on most categories — the always-shown title is capped, and a second "Item Highlight" field carries the rest. | Field | API attribute | Limit | Role | | --- | --- | --- | --- | | Title | `item_name` | **≤75 chars** | The headline shown in search results + on mobile. Brand + core keyword + product type + the one spec that disambiguates. | | Item Highlight | `title_differentiation` | **≤125 chars** | Secondary keywords, scenario, audience, differentiating selling points. Shown on the PDP, indexed for search. | ### Business — how to split it well The split is a relevance-vs-clarity trade-off, not a copy-paste: - **`item_name` is the only thing a mobile shopper sees in search.** Reserve it for the highest-intent terms: brand, the core product noun, the variant-defining spec (size / color / count). Anything that doesn't move click-through on a 6-inch screen loses its seat. - **`title_differentiation` is where long-tail SEO lives.** Move scenario and audience phrases here — "for back-to-school", "travel-friendly", "gift for nurses". It's indexed, so you keep discoverability without bloating the mobile title. - **Cost.** Rewriting an existing catalog is the real expense: an agent pass over *N* SKUs, plus a short-term ranking flux while Amazon re-indexes the new title signals. Run it on top sellers first; batch the long tail. - **Effect.** Cleaner mobile titles (higher CTR where most buyers actually are) and a dedicated SEO surface that stops you cramming 200 chars into the headline. In competitive categories the modular structure is already table stakes. Rule of thumb: if a phrase would make the mobile title wrap to two lines, it belongs in `title_differentiation`. ### Technical — send both fields Same Listings Items API, same array-of-`{ marketplace_id, language_tag, value }` shape — just two attributes instead of one. On **PUT** (create), include both: ```json "item_name": [ { "value": "AcmeGear 24-inch Hardside Suitcase", "language_tag": "en_US", "marketplace_id": "ATVPDKIKX0DER" } ], "title_differentiation": [ { "value": "Lightweight checked luggage for international travel — 4 spinner wheels, TSA lock", "language_tag": "en_US", "marketplace_id": "ATVPDKIKX0DER" } ] ``` On **PATCH** (update either field), two ops: ```json "patches": [ { "op": "replace", "path": "/attributes/item_name", "value": [{ "value": "AcmeGear 24-inch Hardside Suitcase", "language_tag": "en_US" }] }, { "op": "replace", "path": "/attributes/title_differentiation", "value": [{ "value": "Lightweight checked luggage for international travel…", "language_tag": "en_US" }] } ] ``` Agent implementation notes — mirror the read / build / diff loop you already run for `item_name`: - **Read**: a GET returns both fields — read `title_differentiation` alongside `item_name`. - **Build patches**: if your optimized payload carries `title_differentiation`, emit a second patch for `/attributes/title_differentiation`; otherwise omit it. - **Diff**: compare both fields, not just `item_name`, or highlight changes silently slip through. - **Backward compatible**: send only `item_name` and the listing behaves as a single-line title exactly as before; send both and you get the modular structure. Nothing else in the payload changes. ## Step 4 — PUT the listing Use the **Listings Items API 2021-08-01**, not the legacy `JSON_LISTINGS_FEED` via the Feeds API. The modern PUT endpoint is synchronous-per-SKU, returns a `submissionId` you can poll, and gives per-attribute error messages. The Feeds API is still fine for batch imports of 10,000+ SKUs but is overkill for a single new listing. ```ts async function putListing(params: { sellerId: string sku: string marketplaceId: string productType: string // e.g. "LUGGAGE" attributes: Record // base payload + fulfillment_availability }) { const token = await getAccessToken(params.sellerId) const url = new URL( `/listings/2021-08-01/items/${params.sellerId}/${encodeURIComponent(params.sku)}`, "https://sellingpartnerapi-na.amazon.com", ) url.searchParams.set("marketplaceIds", params.marketplaceId) const res = await fetch(url, { method: "PUT", headers: { "x-amz-access-token": token, "Content-Type": "application/json" }, body: JSON.stringify({ productType: params.productType, requirements: "LISTING", attributes: params.attributes, }), }) if (!res.ok) throw new Error(`PUT listing failed: ${res.status} ${await res.text()}`) return res.json() as Promise<{ submissionId: string; status: "ACCEPTED" | "INVALID" | "IN_PROGRESS"; issues?: unknown[] }> } ``` ## Step 5 — Iterate on `90220` until `ACCEPTED` Every write returns a `submissionId` and a `status`. The states: - **`ACCEPTED`** — the payload validated and the listing is stored. Move on (then see Step 6). - **`INVALID`** — validation failed. **No ASIN is created**, so iterating is safe and free. The `issues` array carries `code`, `message`, `severity`, and `attributeNames`. Add the named fields and re-PUT the same SKU (the operation is idempotent). - **`IN_PROGRESS`** — Amazon is still processing. Poll `GET /listings/2021-08-01/items/{sellerId}/{sku}?marketplaceIds={id}&includedData=issues` every 30s. Don't poll faster; the write and read APIs share a rate bucket. The `INVALID` you will hit most is **`90220` — "X is required but missing"** — and the fields it names are the category-conditional ones the schema's top-level `required` did not list. This is normal; expect one or two rounds. A real first PUT came back with eight missing fields; adding them with valid values and re-PUTting returned `ACCEPTED` with an empty `issues` array. That loop — PUT → read `90220` → fix → re-PUT — is the core creation workflow, not an edge case. Other `INVALID` codes: | Code | Cause | Fix | | --- | --- | --- | | `90220` | Missing required attribute for product type | Add the named field; re-PUT. Repeat until clean. | | `88900` | GTIN not owned by seller or already used | Verify barcode ownership; switch to GTIN exemption or `merchant_suggested_asin`. | | `100730` | Identical listing already being processed (duplicate) | Don't create two SKUs with identical product data; bind the second to the first's ASIN via `merchant_suggested_asin`. | | `4000004` | Marketplace not eligible for product type | Check `productTypes` availability in target marketplace. | | `18027` | Price outside marketplace min/max | Adjust `our_price`; some categories have Amazon-set floors. | ## Step 6 — Handle the catalog-review gate (`100521`) `ACCEPTED` means your payload is stored and correct — but for a **new** catalog entry (especially one created via GTIN exemption), Amazon runs a catalog review before the listing goes live. The GET then returns issue `100521`: > "We are reviewing this listing to determine if any additional information is required. Please allow up to 48 hours… otherwise the listing will be published." Distinguish two things an agent often conflates: - **Listing data stored correctly** — verifiable immediately via GET: the attributes you sent are all present. Creation succeeded. - **ASIN assigned / buyable** — lags by minutes to ~48h while review runs. `summaries[].asin` is populated once the ASIN is assigned; `summaries[].status` moves `DISCOVERABLE` (searchable, not purchasable) → `BUYABLE` (purchasable). Poll the GET; only treat `BUYABLE` as "live." Two post-submission states an agent must not misread: - **Seller Central lags the API by ~1–2 hours.** A brand-new listing routinely shows as the wrong channel, "Missing offer", or quantity 0 in Seller Central even when the API already reports the correct `fulfillment_availability` channel and quantity. **Judge channel and inventory from the API (`GET .../items/{sellerId}/{sku}?includedData=attributes`), not from SC display in the first ~2 hours** — and do not "fix" a channel that the API says is already correct. - **`18320 SEARCH_SUPPRESSED` — no main image.** A listing without `main_product_image_locator` can be `BUYABLE` (purchasable via a direct link) but is suppressed from search results. Add at least one image to make it discoverable. ## Step 7 — Choose your fulfillment channel Set the channel in the same PUT (Step 4) — it is one attribute, `fulfillment_availability`: - **FBM / MFN** — `fulfillment_availability: [{ "fulfillment_channel_code": "DEFAULT", "quantity": N }]` plus `merchant_shipping_group` (your shipping-template UUID). You hold and ship the stock. → Full walkthrough: [Create an FBM Listing Using SP-API](/learn/create-fbm-listing-with-sp-api). - **FBA / AFN** — `fulfillment_availability: [{ "fulfillment_channel_code": "AMAZON_NA" }]`, no quantity. FBA also conditionally requires package dimensions, package weight, and a battery declaration (FCs need them) — fields FBM does not require. Amazon holds and ships the stock once you send an inbound shipment. → Full walkthrough: [Create an FBA Listing Using SP-API](/learn/create-fba-listing-with-sp-api). > ⚠️ **Do not create two listings with identical product data.** Amazon's catalog dedup rejects the second with `100730` ("identical listing … is being processed") and suppresses it with `13013`. If you need a second SKU on the same product (e.g. an FBA offer alongside an FBM one), create the first, wait for its ASIN to appear, then create the second with `merchant_suggested_asin` set to that ASIN — it binds to the same catalog item instead of duplicating it. ## What to build on top Once creation works reliably for one SKU, the same shapes cover: - **Bulk creation** — same PUT body, driven from a spreadsheet or agent-generated batch. Rate limit is ~5 requests per second per seller; use a queue. - **Multi-marketplace listings** — the same SKU can list in multiple marketplaces by putting more entries in each attribute's array. One PUT, many storefronts. Do not build custom retry logic on 429 responses without reading the `x-amzn-RateLimit-Limit` header. SP-API uses a token bucket per operation, and the header tells you the current bucket size — build your rate limiter around that number, not around a wall-clock delay. --- # How to Create an FBM Listing Using Amazon SP-API - URL: https://claw-school.com/learn/create-fbm-listing-with-sp-api - Category: amazon - Difficulty: intermediate - Estimated tokens: 7600 - Last reviewed: 2026-08-03 - Prerequisites: create-amazon-listing-with-sp-api > Create a merchant-fulfilled (FBM/MFN) listing via SP-API by setting fulfillment_availability to DEFAULT with a quantity, plus a shipping template. Covers the FBM payload, MFN inventory sync, handling time, and the DISCOVERABLE-to-BUYABLE transition after catalog review. **After this lesson you will:** - Create an FBM listing with fulfillment_availability set to DEFAULT + quantity in one PUT - Bind a listing to a shipping template via merchant_shipping_group - Sync MFN inventory by updating quantity - Distinguish ACCEPTED (stored) from BUYABLE (live) and interpret the 100521 review gate This is the FBM (Merchant-Fulfilled / MFN) deep dive. The shared machinery — LWA auth, picking a `productType`, reading its definition schema, the product-identifier gate (GTIN / GTIN exemption / ASIN match), the `90220` iterate-until-`ACCEPTED` loop, the post-submission `100521` catalog-review state, and the title rule — lives in [Create an Amazon Listing (FBM or FBA) Using SP-API](/learn/create-amazon-listing-with-sp-api). This page covers only what is specific to FBM. ## What makes FBM different With FBM you store and ship the inventory yourself, so the listing must declare **how much you have** and **how you ship**. Both live in the same `fulfillment_availability` attribute plus one more attribute, `merchant_shipping_group`: | Channel | `fulfillment_availability` | `quantity`? | Extra required attr | Buyable when | | --- | --- | --- | --- | --- | | **FBM (this page)** | `[{ "fulfillment_channel_code": "DEFAULT", "quantity": N }]` | **yes** | `merchant_shipping_group` (shipping-template UUID) | catalog review passes with quantity > 0 | | FBA | `[{ "fulfillment_channel_code": "AMAZON_NA" }]` | no | — | inbound stock arrives at an Amazon FC | `DEFAULT` is the channel code for merchant-fulfilled. It is also the default a listing gets if you omit the attribute entirely — but omitting it leaves quantity at 0, so an explicit `DEFAULT` + `quantity` is how you make the listing actually buyable. ## Prerequisites specific to FBM SP-API can reference these but cannot create them. They must already exist in Seller Central: - **A shipping template** (Settings → Shipping Settings) — defines your ship-from address and rate rules. You need its **UUID** for `merchant_shipping_group`. Find it in the URL or network panel when editing the template. - **A return address** (Settings → Return Settings) — a valid return address is required for a buyable FBM offer. ## Step 1 — PUT the listing with DEFAULT + quantity + shipping template Same endpoint and auth as any listing creation. The FBM specifics are the last three attributes: ```ts async function createFbmListing(params: { sellerId: string sku: string marketplaceId: string productType: string // e.g. "DRAIN_STRAINER" attributes: Record quantity: number // your live merchant stock shippingGroupId: string // shipping-template UUID }) { const token = await getAccessToken(params.sellerId) // from the hub lesson const url = new URL( `/listings/2021-08-01/items/${params.sellerId}/${encodeURIComponent(params.sku)}`, "https://sellingpartnerapi-na.amazon.com", ) url.searchParams.set("marketplaceIds", params.marketplaceId) const res = await fetch(url, { method: "PUT", headers: { "x-amz-access-token": token, "Content-Type": "application/json" }, body: JSON.stringify({ productType: params.productType, requirements: "LISTING", attributes: params.attributes, }), }) if (!res.ok) throw new Error(`PUT listing failed: ${res.status} ${await res.text()}`) return res.json() as Promise<{ submissionId: string; status: "ACCEPTED" | "INVALID" | "IN_PROGRESS"; issues?: unknown[] }> } ``` ### The FBM payload (the channel-specific part) Build the base `attributes` as in the hub lesson — then set the three FBM fields. This is the exact shape that returned `ACCEPTED` for a `DEFAULT`-channel, quantity-2 listing: ```json "fulfillment_availability": [{ "fulfillment_channel_code": "DEFAULT", "quantity": 2 }], "purchasable_offer": [{ "currency": "USD", "audience": "ALL", "marketplace_id": "ATVPDKIKX0DER", "our_price": [{ "schedule": [{ "value_with_tax": 6.99 }] }] }], "list_price": [{ "currency": "USD", "value": 9.99, "marketplace_id": "ATVPDKIKX0DER" }], "merchant_shipping_group": [{ "value": "", "marketplace_id": "ATVPDKIKX0DER" }] ``` Optional but useful for FBM: `lead_time_to_ship_max_days` (handling time) rides inside the same `fulfillment_availability` object — `{ "fulfillment_channel_code": "DEFAULT", "quantity": 2, "lead_time_to_ship_max_days": 2 }`. It is only valid on the `DEFAULT` channel. Every value is still an array of marketplace-scoped objects; the title still follows the 2026 `item_name` (≤75) + `title_differentiation` (≤125) rule — both in the hub lesson. ## Step 2 — Sync MFN inventory by updating quantity Unlike FBA (where Amazon counts inbound stock), FBM quantity is yours to maintain. Whenever merchant stock changes, re-declare it. A targeted PATCH is cheaper than a full PUT: ```json { "productType": "DRAIN_STRAINER", "patches": [{ "op": "replace", "path": "/attributes/fulfillment_availability", "value": [{ "fulfillment_channel_code": "DEFAULT", "quantity": 17, "marketplace_id": "ATVPDKIKX0DER" }] }] } ``` Set `quantity: 0` (or let it lapse) and the offer goes inactive — useful for pausing a listing without deleting it. ## Step 3 — ACCEPTED ≠ buyable: the DISCOVERABLE → BUYABLE transition After `ACCEPTED`, a new listing typically sits in `DISCOVERABLE` (searchable, not purchasable) until the `100521` catalog review completes and the offer propagates. Two things to verify, and not to conflate: - **Listing data stored correctly** — check immediately via GET: `fulfillment_availability` shows `DEFAULT` + your quantity, price and shipping template present. Creation succeeded. - **Status `BUYABLE`** — lags by minutes to ~48h. Poll `GET /listings/2021-08-01/items/{sellerId}/{sku}?includedData=summaries`; `summaries[].status` moves from `DISCOVERABLE` to `BUYABLE`. Only then can a customer actually purchase. Do not treat `DISCOVERABLE` as a failure, but do not treat it as done either — keep polling until `BUYABLE`. ## Common FBM pitfalls - **Omitting `quantity`.** The listing is created but stays at 0 stock → not buyable. `DEFAULT` without `quantity` is the most common silent failure. - **Omitting `merchant_shipping_group`.** Without a shipping template the offer cannot quote shipping and stays non-buyable. - **Setting `quantity` on what should be FBA.** Wrong channel code — see the [FBA lesson](/learn/create-fba-listing-with-sp-api). - **Treating `ACCEPTED` as `BUYABLE`.** It only means the payload validated. Confirm `BUYABLE` status separately. For the shared foundation — auth, product types, the identifier gate, the `90220` loop, the title rule, rate limiting — see [the hub lesson](/learn/create-amazon-listing-with-sp-api). ---