<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: FastoGT Team</title>
    <description>The latest articles on DEV Community by FastoGT Team (@fastogt_team).</description>
    <link>https://dev.to/fastogt_team</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3840487%2Fc2b364ce-360c-4228-9eba-5e03cca32d57.png</url>
      <title>DEV Community: FastoGT Team</title>
      <link>https://dev.to/fastogt_team</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fastogt_team"/>
    <language>en</language>
    <item>
      <title>We wrote integrations for two marketplaces and skipped the shared interface</title>
      <dc:creator>FastoGT Team</dc:creator>
      <pubDate>Sun, 16 Aug 2026 17:04:40 +0000</pubDate>
      <link>https://dev.to/fastogt_team/we-wrote-integrations-for-two-marketplaces-and-skipped-the-shared-interface-ig5</link>
      <guid>https://dev.to/fastogt_team/we-wrote-integrations-for-two-marketplaces-and-skipped-the-shared-interface-ig5</guid>
      <description>&lt;p&gt;There is a task that looks like a textbook example of polymorphism: "sync stock and prices with marketplaces". There are several platforms, the actions are the same — list the cards, push the stock, push the price, pull the orders. An interface asks to be written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Marketplace&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ListOffers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="n"&gt;Offer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;SetStock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offerID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;
    &lt;span class="n"&gt;SetPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offerID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;
    &lt;span class="n"&gt;Orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;since&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We wrote Ozon, then Wildberries — the two largest marketplaces in Russia and the CIS — and that interface fell apart on every one of its four methods. Below is where exactly, and why, with code. At the end I will say what turned out to be genuinely shared: one thing, and not the one I would have bet on.&lt;/p&gt;

&lt;p&gt;For context: we build a self-hosted store for sellers who already trade on those platforms. One Go binary, SQLite, one installation is one shop. The code is open, link at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The differences at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Ozon&lt;/th&gt;
&lt;th&gt;Wildberries&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API hosts&lt;/td&gt;
&lt;td&gt;one&lt;/td&gt;
&lt;td&gt;four&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Client-Id&lt;/code&gt; + &lt;code&gt;Api-Key&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;a single &lt;code&gt;Authorization&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stock key&lt;/td&gt;
&lt;td&gt;the product's &lt;code&gt;offer_id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the size's barcode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price key&lt;/td&gt;
&lt;td&gt;the same &lt;code&gt;offer_id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the card's &lt;code&gt;nmID&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Answer to a stock push&lt;/td&gt;
&lt;td&gt;a per-item result list&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;204&lt;/code&gt; with an empty body&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who is to blame on refusal&lt;/td&gt;
&lt;td&gt;stated per item&lt;/td&gt;
&lt;td&gt;only in a &lt;code&gt;409&lt;/code&gt;, by barcode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Answer to a price push&lt;/td&gt;
&lt;td&gt;immediate&lt;/td&gt;
&lt;td&gt;a task id, the result later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure inside HTTP 200&lt;/td&gt;
&lt;td&gt;never&lt;/td&gt;
&lt;td&gt;happens, it is in the body&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Order status&lt;/td&gt;
&lt;td&gt;inside the order&lt;/td&gt;
&lt;td&gt;a separate call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pause on 429&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Retry-After&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;usually &lt;code&gt;X-Ratelimit-Retry&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now one by one.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. One key versus two
&lt;/h2&gt;

&lt;p&gt;On Ozon a product is an &lt;code&gt;offer_id&lt;/code&gt; — your own article number, the one you uploaded to the cabinet yourself. Both stock and price are set by it. One link row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;ozon_links&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;product_id&lt;/span&gt;   &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;offer_id&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Wildberries a card is an &lt;code&gt;nmID&lt;/code&gt;, a card has sizes, and a size has barcodes. Stock is set &lt;strong&gt;per barcode&lt;/strong&gt;, price &lt;strong&gt;per card&lt;/strong&gt;. Those are two identifiers at different levels of nesting, and both have to be stored:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;wb_links&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;product_id&lt;/span&gt;   &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;nm_id&lt;/span&gt;        &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;barcode&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_wb_links_barcode&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;wb_links&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;barcode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt;        &lt;span class="n"&gt;idx_wb_links_nm&lt;/span&gt;      &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;wb_links&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nm_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the indexes: the barcode is unique, the &lt;code&gt;nmID&lt;/code&gt; is not. Two products sharing one barcode would push two different levels into the same slot on every pass. Several rows sharing an &lt;code&gt;nmID&lt;/code&gt; is normal — those are the sizes of one card.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SetStock(offerID string, ...)&lt;/code&gt; is already wrong here: it has no room for the second key, and which of the two to pass depends on what you are doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A success nobody told you about
&lt;/h2&gt;

&lt;p&gt;Ozon answers a stock push with a per-item result list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;ItemResult&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;OfferID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;      &lt;span class="s"&gt;`json:"offer_id"`&lt;/span&gt;
    &lt;span class="n"&gt;Updated&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;        &lt;span class="s"&gt;`json:"updated"`&lt;/span&gt;
    &lt;span class="n"&gt;Errors&lt;/span&gt;  &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;itemError&lt;/span&gt; &lt;span class="s"&gt;`json:"errors"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An item the platform said nothing about counts as &lt;strong&gt;not pushed&lt;/strong&gt; for us, rather than "fine by default": remembering a level the platform never received is worse than sending it twice.&lt;/p&gt;

&lt;p&gt;Wildberries answers &lt;code&gt;PUT /api/v3/stocks/{warehouseId}&lt;/code&gt; with &lt;code&gt;204&lt;/code&gt; and an empty body. There is nothing to parse: the whole batch is credited at once. It names the guilty only in a &lt;code&gt;409&lt;/code&gt;, and only the ones that are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;SetStocks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;warehouseID&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;StockItem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%s/api/v3/stocks/%d"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;marketplace&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;warehouseID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;do&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PUT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stocksRequest&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Stocks&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="c"&gt;// 204: everything accepted&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;apiErr&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;APIError&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;As&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;apiErr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;apiErr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusConflict&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="c"&gt;// transport died, do not credit the batch&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c"&gt;// 409: the body lists refusals per barcode&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signatures diverged too: &lt;code&gt;([]ItemResult, error)&lt;/code&gt; on one side, &lt;code&gt;(map[string]string, error)&lt;/code&gt; on the other. You can force them into a common type, but half of its fields will always be empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A failure inside HTTP 200
&lt;/h2&gt;

&lt;p&gt;The classic trap, and Ozon does not have it. Some Wildberries methods answer with a 200 and put the failure in the body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"errorText"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Miss that check and the push is recorded as successful and never retried. So the check lives in the transport, not in each method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="n"&gt;errorEnvelope&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;APIError&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same place strips the token out of the platform's text before it reaches a log or the database. The token travels in a header and cannot leak by itself, but an API that echoes the request back would carry the key into our files. One line is cheaper than finding out whether it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A price that is applied later
&lt;/h2&gt;

&lt;p&gt;This is where the interface breaks for good.&lt;/p&gt;

&lt;p&gt;Ozon answers a price push the way it answers stock: a verdict per item, immediately. So a row's state is one number, the last value pushed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;kOzonPriceGuard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;`offer_id != '' AND price &amp;gt; 0
     AND (price + 99) / 100 * 100 != price_pushed`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rounding up to whole roubles inside the guard is not decoration. The platform accepts whole roubles, so &lt;code&gt;price_pushed&lt;/code&gt; holds the rounded value, and comparing raw kopecks against a rounded number would mean "this row changed" on every pass until the end of time.&lt;/p&gt;

&lt;p&gt;Wildberries answers &lt;code&gt;POST /api/v2/upload/task&lt;/code&gt; with a &lt;strong&gt;task id&lt;/strong&gt;. What happened to the prices is a separate request, on the next pass or later. "Sent" and "accepted" are two different facts, and that needs two columns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;price_pushed&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;-- the platform confirmed&lt;/span&gt;
&lt;span class="n"&gt;price_sent&lt;/span&gt;   &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;-- what the task carries&lt;/span&gt;
&lt;span class="n"&gt;price_task&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt;    &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;-- which task carries it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the guard has to exclude rows with a task in flight, otherwise the next tick uploads everything again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;kWBPriceGuard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;`nm_id != 0 AND price &amp;gt; 0 AND price_task = ''
     AND (price + 99) / 100 * 100 != price_pushed`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A separate table &lt;code&gt;wb_price_tasks(upload_id, created_at)&lt;/code&gt; exists purely for &lt;code&gt;created_at&lt;/code&gt;. Without a creation time, a task the platform forgot about is indistinguishable from one still running, and its products would hang in the "in flight" state forever. After an hour those rows are released with a readable error and return to the normal retry cycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. One price per card, several products
&lt;/h2&gt;

&lt;p&gt;Same place, continued. A Wildberries card has several sizes and one price for all of them. On our side each size can be a separate product with its own price — that is exactly what a catalogue imported from WB itself looks like.&lt;/p&gt;

&lt;p&gt;So what do you do when two sizes disagree on the price? Any answer of the sort "take the first" or "take the lowest" is silently repricing somebody's goods. We send nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;groupByCard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WBPriceRow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agreed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;conflicted&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;cardGroup&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;byNm&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;cardGroup&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;byNm&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NmID&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;byNm&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NmID&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cardGroup&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;nmID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NmID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Price&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="c"&gt;// the sizes did not agree&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every row of such a card gets an error explaining why, and the owner sees it in the UI. This is the case where doing nothing is the only honest behaviour, and no shared interface would ever suggest it: on Ozon the situation does not exist at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Orders: where the status lives
&lt;/h2&gt;

&lt;p&gt;An Ozon posting is multi-line and carries its status inside. One call to &lt;code&gt;/v3/posting/fbs/list&lt;/code&gt; gives you both the contents and the state.&lt;/p&gt;

&lt;p&gt;A Wildberries assembly task is &lt;strong&gt;one item&lt;/strong&gt;, and the list carries no status whatsoever. You have to ask for it with a second call, &lt;code&gt;POST /api/v3/orders/status&lt;/code&gt;, passing the ids of the tasks that can still change.&lt;/p&gt;

&lt;p&gt;Two things follow. First, we have no &lt;code&gt;wb_order_items&lt;/code&gt; table. Copying a pair of tables and a join to hold exactly one row per order is structure for the sake of symmetry with somebody else's code.&lt;/p&gt;

&lt;p&gt;Second, and nastier: without that second call a cancellation is invisible, and a cancelled order holds our stock hostage. Ozon tells you about cancellations itself. WB does not.&lt;/p&gt;

&lt;p&gt;Idempotency in both cases rests on UNIQUE, not on a "have we applied this already" check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;   &lt;span class="c1"&gt;-- the assembly task id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Specifically the task &lt;code&gt;id&lt;/code&gt;, not the &lt;code&gt;rid&lt;/code&gt;: one &lt;code&gt;rid&lt;/code&gt; produces several tasks, and keying on it would lose sales. The INSERT either creates the row (the order is new, deduct the stock) or does nothing. A SELECT before the INSERT does not help — two passes slip through the gap between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The retry header
&lt;/h2&gt;

&lt;p&gt;A detail that costs you a blocked key. On a 429 Ozon answers with &lt;code&gt;Retry-After&lt;/code&gt;, Wildberries usually with &lt;code&gt;X-Ratelimit-Retry&lt;/code&gt;. Read both and obey the platform instead of your own timer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;retryAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"X-Ratelimit-Retry"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Retry-After"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Atoi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;sec&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What turned out to be shared
&lt;/h2&gt;

&lt;p&gt;One thing: the arithmetic of the markup ladder. Not the platform interface, not the transport, not the order model — a thirty-line function that picks a multiplier from the shelf price:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;ApplyRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rules&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;PriceRule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shelf&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;rules&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UpTo&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;shelf&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UpTo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shelf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Multiplier&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It moved into a shared file together with its validation and its tests. The rule tables stayed separate — &lt;code&gt;ozon_price_rules&lt;/code&gt; and &lt;code&gt;wb_price_rules&lt;/code&gt; — because a markup differs per platform. That is data, not code.&lt;/p&gt;

&lt;p&gt;Everything else is two independent packages, &lt;code&gt;app/ozon&lt;/code&gt; and &lt;code&gt;app/wb&lt;/code&gt;, each with its own client, worker, tables prefixed by the platform, and its own rules. The core (&lt;code&gt;products&lt;/code&gt;, &lt;code&gt;orders&lt;/code&gt;) knows nothing about platforms. Shared code between the two: zero lines, apart from that one function.&lt;/p&gt;

&lt;h2&gt;
  
  
  But isn't that copy-paste?
&lt;/h2&gt;

&lt;p&gt;The expected objection: "you have two nearly identical workers". They do look alike — a ticker, a wake channel, two counters, a retry ladder. The difference is that one &lt;code&gt;Pass()&lt;/code&gt; has three steps and the other has four, and the extra step, settling asynchronous tasks, must run first, otherwise its rows are invisible to the price push.&lt;/p&gt;

&lt;p&gt;A shared worker with a "does this platform have async prices" parameter is not reuse, it is an &lt;code&gt;if&lt;/code&gt; inside the abstraction that promised to hide it. A third platform will bring a fourth set of rules, and &lt;em&gt;then&lt;/em&gt;, with three finished slices in hand, it will be possible to see what they really have in common. Deriving an abstraction from one implementation is a way of learning about the second platform too late.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this is tested
&lt;/h2&gt;

&lt;p&gt;Both integrations use an &lt;code&gt;httptest&lt;/code&gt; server covering the whole client plus an in-memory SQLite, with requests going through the real chi router rather than around it. The fake cabinet can do what needs proving: answer &lt;code&gt;204&lt;/code&gt; or a &lt;code&gt;409&lt;/code&gt; naming specific barcodes, return a 429 with a header, hold a price task in the "running" state, and record the order of calls.&lt;/p&gt;

&lt;p&gt;That last one turned out more useful than expected. A test on the order of steps inside a pass catches a regression that is otherwise only visible on real money:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;want&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"stocks"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"prices"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Orders first, then stock, then prices. Swap the first two and the shop pushes a stock level that does not yet account for the sale that just happened on the platform. That is an oversell of your own making.&lt;/p&gt;




&lt;p&gt;The code is open under AGPL-3.0: &lt;a href="https://github.com/fastogt/fastoshop" rel="noopener noreferrer"&gt;github.com/fastogt/fastoshop&lt;/a&gt;. The integrations live in &lt;code&gt;src/app/ozon&lt;/code&gt; and &lt;code&gt;src/app/wb&lt;/code&gt;, the shared arithmetic in &lt;code&gt;src/app/database/price_rules.go&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you have integrated with marketplaces in other regions — Amazon, Allegro, Shopee, Mercado Libre — I would be curious whether the same pattern holds: does the shared interface survive the second platform, or does it break on the same kind of detail?&lt;/p&gt;

</description>
      <category>go</category>
      <category>api</category>
      <category>opensource</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>DevJobs: A Real-Time Developer Job Aggregator Updated Every Hour</title>
      <dc:creator>FastoGT Team</dc:creator>
      <pubDate>Mon, 23 Mar 2026 16:40:14 +0000</pubDate>
      <link>https://dev.to/fastogt_team/devjobs-a-real-time-developer-job-aggregator-updated-every-hour-5go6</link>
      <guid>https://dev.to/fastogt_team/devjobs-a-real-time-developer-job-aggregator-updated-every-hour-5go6</guid>
      <description>&lt;p&gt;Tired of checking 10 different job boards every day? &lt;strong&gt;DevJobs&lt;/strong&gt; aggregates developer jobs from multiple sources and updates every hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Makes DevJobs Different
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real-time aggregation&lt;/strong&gt; — jobs pulled from multiple sources hourly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4000+ active listings&lt;/strong&gt; — constantly refreshed, no stale posts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter by region&lt;/strong&gt; — find jobs in your area or remote&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter by skill&lt;/strong&gt; — JavaScript, Python, Go, DevOps, and more&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No account required&lt;/strong&gt; — just browse and apply&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Frontend / Backend / Full-stack&lt;/li&gt;
&lt;li&gt;DevOps / SRE / Cloud&lt;/li&gt;
&lt;li&gt;Mobile (iOS, Android)&lt;/li&gt;
&lt;li&gt;Data Science / ML&lt;/li&gt;
&lt;li&gt;And more...&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why We Built It
&lt;/h2&gt;

&lt;p&gt;Job hunting is already stressful enough. We wanted a single place where developers can see all available opportunities without visiting multiple sites.&lt;/p&gt;

&lt;p&gt;🌐 &lt;strong&gt;Browse jobs now&lt;/strong&gt;: &lt;a href="https://devjobs.pro" rel="noopener noreferrer"&gt;devjobs.pro&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Bookmark it, check it daily, find your next role.&lt;/p&gt;

</description>
      <category>jobs</category>
      <category>career</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>FastoLead: AI-Powered Lead Scoring That Actually Works</title>
      <dc:creator>FastoGT Team</dc:creator>
      <pubDate>Mon, 23 Mar 2026 16:40:12 +0000</pubDate>
      <link>https://dev.to/fastogt_team/fastolead-ai-powered-lead-scoring-that-actually-works-n3i</link>
      <guid>https://dev.to/fastogt_team/fastolead-ai-powered-lead-scoring-that-actually-works-n3i</guid>
      <description>&lt;p&gt;Sales teams waste up to 50% of their time on leads that will never convert. &lt;strong&gt;FastoLead&lt;/strong&gt; uses AI to score and prioritize your leads so your team focuses on what matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Too many leads, not enough time&lt;/li&gt;
&lt;li&gt;Manual lead qualification is slow and inconsistent&lt;/li&gt;
&lt;li&gt;Good leads slip through the cracks&lt;/li&gt;
&lt;li&gt;CRM data is messy and incomplete&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How FastoLead Helps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AI-powered scoring&lt;/strong&gt; — automatically rank leads by conversion probability&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smart prioritization&lt;/strong&gt; — your team works the best leads first&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pattern recognition&lt;/strong&gt; — learns from your historical data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time updates&lt;/strong&gt; — scores adjust as new signals come in&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CRM integration&lt;/strong&gt; — works with your existing tools&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;Teams using FastoLead typically see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;30-40% increase in conversion rates&lt;/li&gt;
&lt;li&gt;50% reduction in time spent on unqualified leads&lt;/li&gt;
&lt;li&gt;Better alignment between marketing and sales&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;🌐 &lt;strong&gt;Try it&lt;/strong&gt;: &lt;a href="https://fastolead.com" rel="noopener noreferrer"&gt;fastolead.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Stop guessing which leads are worth your time. Let AI do the heavy lifting.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>saas</category>
      <category>sales</category>
      <category>productivity</category>
    </item>
    <item>
      <title>CrocOTT: Self-Hosted White-Label OTT/IPTV Platform for Your Business</title>
      <dc:creator>FastoGT Team</dc:creator>
      <pubDate>Mon, 23 Mar 2026 16:34:50 +0000</pubDate>
      <link>https://dev.to/fastogt_team/crocott-self-hosted-white-label-ottiptv-platform-for-your-business-1jof</link>
      <guid>https://dev.to/fastogt_team/crocott-self-hosted-white-label-ottiptv-platform-for-your-business-1jof</guid>
      <description>&lt;p&gt;Launching your own streaming service shouldn't require a team of 50 engineers. &lt;strong&gt;CrocOTT&lt;/strong&gt; is a white-label OTT/IPTV platform that lets you deploy your own video streaming service on your infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CrocOTT Offers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;White-label apps&lt;/strong&gt; — branded apps for Android, iOS, Smart TV, Fire TV, web&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted&lt;/strong&gt; — deploy on your own Linux servers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subscriber management&lt;/strong&gt; — built-in billing and user management&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content management&lt;/strong&gt; — EPG, VOD catalog, live channels&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transparent pricing&lt;/strong&gt; — per-subscriber model, no hidden fees&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who Is It For?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Telecom operators&lt;/strong&gt; launching IPTV services&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content creators&lt;/strong&gt; building their own streaming platform&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hotels and businesses&lt;/strong&gt; needing in-house TV systems&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Entrepreneurs&lt;/strong&gt; starting a streaming business&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Deploy CrocOTT on your server&lt;/li&gt;
&lt;li&gt;Configure your channels and content&lt;/li&gt;
&lt;li&gt;Customize your branded apps&lt;/li&gt;
&lt;li&gt;Start onboarding subscribers&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;🌐 &lt;strong&gt;Learn more&lt;/strong&gt;: &lt;a href="https://crocott.com" rel="noopener noreferrer"&gt;crocott.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you're looking for a turnkey streaming solution without the SaaS dependency, CrocOTT might be exactly what you need.&lt;/p&gt;

</description>
      <category>streaming</category>
      <category>saas</category>
      <category>selfhosted</category>
      <category>iptv</category>
    </item>
    <item>
      <title>FastoCloud: Open-Source Middleware for OTT/IPTV, CCTV, and WebRTC</title>
      <dc:creator>FastoGT Team</dc:creator>
      <pubDate>Mon, 23 Mar 2026 16:34:49 +0000</pubDate>
      <link>https://dev.to/fastogt_team/fastocloud-open-source-middleware-for-ottiptv-cctv-and-webrtc-3gkh</link>
      <guid>https://dev.to/fastogt_team/fastocloud-open-source-middleware-for-ottiptv-cctv-and-webrtc-3gkh</guid>
      <description>&lt;p&gt;If you're building a video streaming platform, you know how complex the middleware layer can be. That's why we built &lt;strong&gt;FastoCloud&lt;/strong&gt; — a complete open-source middleware solution for video suppliers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is FastoCloud?
&lt;/h2&gt;

&lt;p&gt;FastoCloud is a self-hosted media server that handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OTT/IPTV streaming&lt;/strong&gt; — live TV, VOD, catch-up TV&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CCTV &amp;amp; video recording&lt;/strong&gt; — surveillance and object detection&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebRTC&lt;/strong&gt; — real-time communication&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Computer vision&lt;/strong&gt; — AI-powered video analysis&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why FastoCloud?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open source&lt;/strong&gt; — full transparency, no vendor lock-in&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosted&lt;/strong&gt; — your infrastructure, your data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GStreamer-based&lt;/strong&gt; — battle-tested media pipeline&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-platform players&lt;/strong&gt; — apps for Android, iOS, Smart TV, web&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;REST API&lt;/strong&gt; — integrate with your existing systems&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;

&lt;p&gt;Check out the source code and documentation:&lt;/p&gt;

&lt;p&gt;🔗 &lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/fastogt/fastocloud" rel="noopener noreferrer"&gt;github.com/fastogt/fastocloud&lt;/a&gt;&lt;br&gt;
🌐 &lt;strong&gt;Website&lt;/strong&gt;: &lt;a href="https://fastocloud.com" rel="noopener noreferrer"&gt;fastocloud.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We'd love to hear your feedback and contributions. Star the repo if you find it useful!&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>streaming</category>
      <category>selfhosted</category>
      <category>webrtc</category>
    </item>
  </channel>
</rss>
