<?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: Alexander Schneider</title>
    <description>The latest articles on DEV Community by Alexander Schneider (@alexanderschneider).</description>
    <link>https://dev.to/alexanderschneider</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%2F3345203%2Ffe1c9ad3-dcad-4345-8553-3d8949dedbc4.jpeg</url>
      <title>DEV Community: Alexander Schneider</title>
      <link>https://dev.to/alexanderschneider</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alexanderschneider"/>
    <language>en</language>
    <item>
      <title>Configuring Stripe for a European SaaS Startup: A Field Guide</title>
      <dc:creator>Alexander Schneider</dc:creator>
      <pubDate>Tue, 09 Jun 2026 14:48:54 +0000</pubDate>
      <link>https://dev.to/alexanderschneider/configuring-stripe-for-a-european-saas-startup-a-field-guide-1bla</link>
      <guid>https://dev.to/alexanderschneider/configuring-stripe-for-a-european-saas-startup-a-field-guide-1bla</guid>
      <description>&lt;p&gt;Most Stripe tutorials stop at "create a Checkout Session and listen for &lt;code&gt;checkout.session.completed&lt;/code&gt;." That gets you a demo. It does not get you a billing system you can trust with real European customers, real SEPA debits, real VAT and real cancellations.&lt;/p&gt;

&lt;p&gt;This guide collects the configuration decisions that actually bite once money flows. It is written from the perspective of an EU-incorporated SaaS selling subscriptions to both consumers and businesses, across EUR and USD. Every code sample is generic. Adapt the IDs and plan names to your own account.&lt;/p&gt;

&lt;p&gt;The running theme: your Stripe Dashboard config and your webhook code are two halves of one system. When they drift apart, customers pay and get nothing, or cancel and keep access. Most of the work below is keeping the two halves in sync.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Treat the webhook as your source of truth
&lt;/h2&gt;

&lt;p&gt;The browser redirect after Checkout is a UI convenience. It is not proof that money moved. A user can close the tab, lose connection or pay with a method that settles days later. The only reliable signal that a subscription exists and is paid is a verified webhook event.&lt;/p&gt;

&lt;p&gt;So provisioning, plan changes, access revocation and dunning all hang off webhooks. Get this layer right and the rest is configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify every signature
&lt;/h3&gt;

&lt;p&gt;Never trust a webhook body you have not verified. Stripe signs each delivery with your endpoint secret.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// raw body, not parsed JSON&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// event is now trustworthy&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two gotchas that cost people hours:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Verify against the &lt;strong&gt;raw&lt;/strong&gt; request body. If your framework parses JSON before you reach the handler, re-serializing it changes bytes and the signature check fails. Capture the raw body first.&lt;/li&gt;
&lt;li&gt;On edge and serverless runtimes without Node's &lt;code&gt;crypto&lt;/code&gt;, use &lt;code&gt;constructEventAsync&lt;/code&gt; instead of &lt;code&gt;constructEvent&lt;/code&gt;. It uses the Web Crypto API and returns a promise.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Subscribe to the events your code actually handles
&lt;/h3&gt;

&lt;p&gt;Two failure modes, equally common. You subscribe to events your code ignores, so deliveries pile up as no-ops. Or your code handles events the Dashboard never sends, so a whole class of customers silently never gets provisioned. Audit both directions on a schedule.&lt;/p&gt;

&lt;p&gt;For a subscription product, this is a sane baseline event set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;checkout.session.completed
checkout.session.async_payment_succeeded
checkout.session.async_payment_failed
checkout.session.expired
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
invoice.paid
invoice.payment_failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not every event in that list does the same kind of work. Separate them in your own head and in your code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Provisioning events&lt;/strong&gt; change account state and trigger email: the checkout completions, the subscription lifecycle, the invoice outcomes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tracking events&lt;/strong&gt; feed analytics and observability only: expired sessions, failed async payments. They should return &lt;code&gt;200&lt;/code&gt; quickly and do nothing else.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PROVISIONING_EVENTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checkout.session.completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checkout.session.async_payment_succeeded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;customer.subscription.created&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;customer.subscription.updated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;customer.subscription.deleted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invoice.paid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invoice.payment_failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TRACKING_EVENTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checkout.session.expired&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checkout.session.async_payment_failed&lt;/span&gt;&lt;span class="dl"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TRACKING_EVENTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;recordAnalytics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// no state change&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;PROVISIONING_EVENTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&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="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// unknown type, acknowledge and ignore&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason to spell this out: when someone asks "should we add &lt;code&gt;checkout.session.expired&lt;/code&gt; to the webhook," the honest answer is "yes, but only for analytics, it will not provision anyone." Mixing the two categories leads teams to over-prioritize harmless events and under-prioritize the one that actually loses revenue. More on that one next.&lt;/p&gt;

&lt;h3&gt;
  
  
  Make handlers idempotent
&lt;/h3&gt;

&lt;p&gt;Stripe retries deliveries, and a single business action can arrive as several events. Store each processed &lt;code&gt;event.id&lt;/code&gt; and skip duplicates, or make your write operations safe to repeat.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;alreadyProcessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;provision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;markProcessed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Always return 2xx fast
&lt;/h3&gt;

&lt;p&gt;Acknowledge within Stripe's timeout, then do slow work asynchronously. A handler that calls three downstream services inline will start timing out under load, and Stripe will retry, which multiplies your load exactly when you can least afford it. Acknowledge, enqueue, process.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Asynchronous payment methods change the rules
&lt;/h2&gt;

&lt;p&gt;This is the single most overlooked thing for EU sellers, because the methods that cause it are the ones EU customers want.&lt;/p&gt;

&lt;p&gt;Cards and wallets settle synchronously. The customer pays, Stripe knows immediately, &lt;code&gt;checkout.session.completed&lt;/code&gt; arrives with &lt;code&gt;payment_status: 'paid'&lt;/code&gt;. SEPA Direct Debit does not work that way. It can take days to confirm or fail. With SEPA enabled, &lt;code&gt;checkout.session.completed&lt;/code&gt; can arrive with &lt;code&gt;payment_status&lt;/code&gt; of &lt;code&gt;unpaid&lt;/code&gt; or &lt;code&gt;processing&lt;/code&gt;, and the real outcome comes later as a separate event.&lt;/p&gt;

&lt;p&gt;If you provision on &lt;code&gt;checkout.session.completed&lt;/code&gt; without checking &lt;code&gt;payment_status&lt;/code&gt;, you hand out paid access for a debit that has not cleared and might bounce.&lt;/p&gt;

&lt;p&gt;The correct pattern has two parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guard the completion event:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checkout.session.completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payment_status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Delayed method such as SEPA. Do not provision yet.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;provision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&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;&lt;strong&gt;Provision on the async success event instead:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checkout.session.async_payment_succeeded&lt;/span&gt;&lt;span class="dl"&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;await&lt;/span&gt; &lt;span class="nf"&gt;provision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checkout.session.async_payment_failed&lt;/span&gt;&lt;span class="dl"&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;await&lt;/span&gt; &lt;span class="nf"&gt;notifyPaymentFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&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;Here is the trap that catches teams who did the code right but the config wrong. If your handler is correct but &lt;code&gt;checkout.session.async_payment_succeeded&lt;/code&gt; is not subscribed in the Dashboard, then a SEPA customer experiences this: they finish Checkout, the completion event is correctly skipped because it is not yet paid, the async success event fires when the debit clears, and your endpoint never receives it. They paid. They got nothing. No email, no access, no error anywhere you would notice. This is why the async success event is the highest-priority webhook to verify, not the flashy failure ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep your post-checkout page pending-neutral
&lt;/h3&gt;

&lt;p&gt;Because the browser can return before settlement, the confirmation page must not assert that payment succeeded. Write copy that survives both outcomes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Thanks. If your checkout completed successfully, we will activate your plan and email your access details shortly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Avoid "Payment received, you now have access." For SEPA that can be false at the moment it renders. Drive the real "you are live" message from the webhook, by email, once settlement is confirmed. The redirect URL can carry a status flag for analytics, but treat that flag as a hint, never as authorization.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Resolve plans from data, never from display strings
&lt;/h2&gt;

&lt;p&gt;Your webhook receives a price and needs to map it to an internal plan like &lt;code&gt;hobby&lt;/code&gt; or &lt;code&gt;professional&lt;/code&gt;. The mapping you choose determines how robust your billing is for the next three years, including for old prices you forgot about.&lt;/p&gt;

&lt;p&gt;Stripe gives you several fields. Rank them by stability:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Price metadata&lt;/strong&gt; you set deliberately, for example &lt;code&gt;metadata.plan = "professional"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;lookup_key&lt;/code&gt;&lt;/strong&gt;, a stable handle you assign, for example &lt;code&gt;professional_monthly&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The opaque price ID, useful only with an explicit lookup table.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolvePlan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;price&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lookup_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;planFromLookupKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lookup_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c1"&gt;// Last resort: an explicit, reviewed map of legacy IDs.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;LEGACY_PRICE_TO_PLAN&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What not to do: parse the human-readable nickname or product name. Display strings drift, get localized and contain abbreviations. A regex that classifies plans from a nickname will eventually misfire.&lt;/p&gt;

&lt;p&gt;A concrete example of how it misfires. Say you match whole words to be "safe":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Looks careful. Is a landmine.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;professional&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;professional&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A legacy price nicknamed &lt;code&gt;api-pro-monthly&lt;/code&gt; contains &lt;code&gt;pro&lt;/code&gt;, not &lt;code&gt;professional&lt;/code&gt;, so this returns nothing and the active subscription syncs with no plan. Worse, a price nicknamed &lt;code&gt;api-hobby-pro&lt;/code&gt; matches &lt;code&gt;\bhobby\b&lt;/code&gt; first and resolves a Professional subscription as Hobby. Both bugs are invisible until a customer on that old price tries to manage their account.&lt;/p&gt;

&lt;p&gt;The fix is boring and correct: assign &lt;code&gt;lookup_key&lt;/code&gt; and &lt;code&gt;metadata.plan&lt;/code&gt; to every price at creation, treat those as the contract and keep nicknames purely human. When you migrate prices, carry the same &lt;code&gt;lookup_key&lt;/code&gt; forward.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do not lean on &lt;code&gt;client_reference_id&lt;/code&gt; alone
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;client_reference_id&lt;/code&gt; is handy for attribution, but Stripe can omit or drop it, and some setups only attach it for non-bot traffic. Use it as a fallback signal, not the primary key. For Payment Links, the link ID itself is a more stable plan hint than the reference string. Order your resolution: payment link or price first, metadata second, &lt;code&gt;client_reference_id&lt;/code&gt; last.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Pricing across EUR and USD without confusing customers
&lt;/h2&gt;

&lt;p&gt;European SaaS usually wants EUR as the primary currency and USD as an alternate, so US visitors see familiar numbers. Stripe supports multi-currency prices for exactly this. The mistake is letting your marketing copy and your Checkout disagree.&lt;/p&gt;

&lt;p&gt;A common drift: the public pricing page is written in USD because the template came from a US example, say &lt;code&gt;$29&lt;/code&gt; and &lt;code&gt;$299&lt;/code&gt;. The active EUR prices are &lt;code&gt;€29&lt;/code&gt; and &lt;code&gt;€249&lt;/code&gt;. Now a German visitor reads &lt;code&gt;$299&lt;/code&gt;, clicks, and Checkout charges &lt;code&gt;€249&lt;/code&gt;. The Hobby tier happens to read &lt;code&gt;29&lt;/code&gt; in both currencies, so nobody notices there, which makes the Professional mismatch even more confusing when a customer spots it.&lt;/p&gt;

&lt;p&gt;None of this is a Stripe bug. It is a content decision you have to make on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Decide your primary currency and set every active price's alternate currencies to match it deliberately, not by Stripe's auto-conversion if you care about round numbers.&lt;/li&gt;
&lt;li&gt;Make the public site reflect what a visitor in that region will actually be charged, or state the localization clearly.&lt;/li&gt;
&lt;li&gt;Keep the number you advertise and the number Checkout shows reconcilable for every region you sell into.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run a real Checkout from an EU IP and a US IP before launch and screenshot both. The amount on the page and the amount in Checkout should tell the same story.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Stripe Tax is not optional for EU sellers
&lt;/h2&gt;

&lt;p&gt;Selling digital services into the EU means VAT, and VAT means rules that differ by customer type and country. Configure Stripe Tax early, because retrofitting tax onto live subscriptions is painful.&lt;/p&gt;

&lt;h3&gt;
  
  
  Turn on automatic tax and set it per price
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscription&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;line_items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;price_...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;automatic_tax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// collect what you need to compute and document VAT&lt;/span&gt;
  &lt;span class="na"&gt;customer_update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;tax_id_collection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;&lt;code&gt;tax_id_collection&lt;/code&gt; matters for B2B. A business customer in another EU country who provides a valid VAT ID triggers the reverse charge, so Stripe zero-rates the invoice and the customer self-accounts. Without VAT ID collection you charge them VAT they did not owe and they will ask for it back.&lt;/p&gt;

&lt;h3&gt;
  
  
  Register where you owe, then keep filing in view
&lt;/h3&gt;

&lt;p&gt;Cross-border B2C digital sales in the EU fall under the One Stop Shop, which lets you file EU-wide through one registration once you pass the threshold. In your home country you may start as a small seller before you cross the local threshold. Stripe Tax tracks thresholds and flags locations that "need attention." Those flags are operational debt, not decoration. Calculating tax correctly at Checkout does not file it for you. Decide who owns filing and put it on a calendar.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pick tax codes and tax behavior, then be consistent
&lt;/h3&gt;

&lt;p&gt;Two settings drift without anyone deciding they should:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tax code per product.&lt;/strong&gt; SaaS for personal use and SaaS for business use can carry different codes. Assign them on purpose, not by accepting a default on the first product and forgetting on the second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tax behavior: inclusive or exclusive.&lt;/strong&gt; A price can show tax as included in the displayed amount or added on top. If one tier is EUR-inclusive and another is EUR-exclusive, your invoices and your displayed prices stop being comparable across tiers. Pick one policy for a currency and apply it to every price in that currency, or document the exception loudly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Put your VAT ID on every invoice, send finalized invoices and credit notes automatically and confirm invoice numbering is sequential. EU invoice law has formal requirements, and Stripe can satisfy them if you switch the settings on.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Configure the Customer Portal so it cannot strand a customer
&lt;/h2&gt;

&lt;p&gt;The Customer Portal lets customers update payment methods, see invoices, switch plans and cancel without emailing you. It is also easy to misconfigure in ways that only show up when a customer tries something.&lt;/p&gt;

&lt;h3&gt;
  
  
  One portal configuration per product line
&lt;/h3&gt;

&lt;p&gt;If you run more than one product on a single Stripe account, the default portal config can end up offering the wrong product's plans. Create a dedicated portal configuration per product line, restrict its eligible products and prices to that line and route each brand's "manage billing" link to the matching configuration. Then test each link end to end.&lt;/p&gt;

&lt;h3&gt;
  
  
  Decide downgrade timing on purpose
&lt;/h3&gt;

&lt;p&gt;When a customer downgrades, do they lose the higher tier immediately or at the end of the period they already paid for? For most subscription businesses, downgrade at period end is the fair and expected behavior, because the customer paid through that date.&lt;/p&gt;

&lt;p&gt;A sharp edge here: Stripe's period-end downgrade scheduling in the portal is documented for same-product price changes, for example monthly to yearly on one product. If your tiers are &lt;strong&gt;separate Stripe products&lt;/strong&gt; rather than separate prices on one product, a cross-product change such as Professional down to Hobby may not behave as a clean period-end downgrade through the portal alone. Verify it with a live portal test in a test-mode account, and if it does not behave, handle that transition in your own backend by scheduling the swap for the period boundary. Do not assume the setting covers a case Stripe scoped more narrowly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Proration, retention and redirects
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Set proration behavior so plan changes bill the way you intend, and decide whether upgrades charge immediately.&lt;/li&gt;
&lt;li&gt;A retention coupon offered at cancellation can recover real revenue. Wire the same coupon code into both the portal config and your own cancellation email so a customer sees one consistent offer.&lt;/li&gt;
&lt;li&gt;Set the portal return URL to a real page on your site, and the terms and privacy links to your live legal pages.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  7. Dunning: plan for payments that fail later
&lt;/h2&gt;

&lt;p&gt;Cards expire. Debits bounce. Renewals fail. What happens next is a setting, and the defaults are not always what you want.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Smart Retries&lt;/strong&gt; for failed card payments retries on Stripe's optimized schedule. Enable it and pick how aggressive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure emails&lt;/strong&gt; to the customer are toggled per category. The trap for EU sellers: the card-failure email is often on while the &lt;strong&gt;bank-debit&lt;/strong&gt; failure email is off, even though SEPA is enabled. A failed SEPA debit then notifies no one. Turn on the communication for every method you actually accept.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After all retries&lt;/strong&gt;, decide whether the subscription cancels, pauses or goes unpaid, and whether the invoice is marked uncollectible. Make this match how your access control reads subscription status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the labels carefully.&lt;/strong&gt; Stripe's retry settings sometimes name ACH Direct Debit specifically. If you accept SEPA and not ACH, confirm the SEPA-specific behavior rather than assuming the ACH setting applies to you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whatever you choose, the access decision in your app should be driven by webhook-delivered subscription status, so that "payment failed, retries exhausted, subscription canceled" actually removes access without a human in the loop.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Limit subscription sprawl
&lt;/h2&gt;

&lt;p&gt;Two settings and one habit save you from confusing account states.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Limit customers to 1 subscription&lt;/code&gt; is off by default. With it off, the same customer can hold two active subscriptions at once, which makes "what plan is this account on" ambiguous. Turn it on unless you have a real reason to sell parallel subscriptions.&lt;/p&gt;

&lt;p&gt;Payment Links also tend to create a fresh Customer per checkout, so the same email can map to several Stripe Customers over time. Your backend should match accounts by a stable key such as email plus subscription ID, and pick a deterministic rule for which subscription is authoritative when more than one exists. Decide that rule before you need it during an incident.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Price and product hygiene
&lt;/h2&gt;

&lt;p&gt;Old prices do not disappear when you archive them. Subscriptions created on them keep billing on them. That is correct behavior, and it means every archived price with an active subscription is still part of your live billing surface.&lt;/p&gt;

&lt;p&gt;Two practices keep this manageable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every active legacy price must still resolve to a current internal plan, through the metadata and &lt;code&gt;lookup_key&lt;/code&gt; you assigned at creation, or through an explicit reviewed map. If a grandfathered price cannot resolve deterministically, downgrades and cancellations on it become guesswork. Migrate it or map it.&lt;/li&gt;
&lt;li&gt;When you launch new prices, carry forward stable &lt;code&gt;lookup_key&lt;/code&gt; values and &lt;code&gt;metadata.plan&lt;/code&gt; so the resolver in section 3 keeps working without edits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Audit archived-prices-with-active-subscriptions the same way you audit live ones. They charge real customers.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. Security and keys
&lt;/h2&gt;

&lt;p&gt;Billing is the worst place to be lax with credentials.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never put a secret key in frontend or static code. Client code uses the publishable key only. If a secret key ever touches a repo, rotate it.&lt;/li&gt;
&lt;li&gt;Prefer restricted keys scoped to what each service needs, over the account-wide secret key.&lt;/li&gt;
&lt;li&gt;Store the webhook signing secret and the API key as platform secrets, not in committed config.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# example: set secrets out of band, never commit them&lt;/span&gt;
&lt;span class="c"&gt;# most platforms have an equivalent (vercel env, fly secrets, wrangler secret, etc.)&lt;/span&gt;
your-platform secrets &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk_live_...
your-platform secrets &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;whsec_...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Rotate keys on a schedule and after any suspected exposure, and have a rotation runbook for the webhook secret so a rotation does not drop live deliveries.&lt;/li&gt;
&lt;li&gt;Enforce two-factor for everyone with Dashboard access, and review team roles so support staff are not sitting on owner permissions.&lt;/li&gt;
&lt;li&gt;Turn on Radar rules for card-testing protection. A public Checkout is a target for card-testing attacks, where someone runs stolen card numbers through your form. Set risk thresholds and alerting before you find out the hard way.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  11. Coupons and promotion codes
&lt;/h2&gt;

&lt;p&gt;Coupons are easy to create and easy to forget, and a forgotten one can be expensive.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Audit every coupon and promotion code periodically. The dangerous ones are internal test coupons, the &lt;code&gt;99% off forever&lt;/code&gt; you made during development and never deleted.&lt;/li&gt;
&lt;li&gt;If a coupon should not reach customers, make sure it has no active promotion code that someone could type into Checkout. The coupon existing is fine. A live, enterable code attached to it is the risk.&lt;/li&gt;
&lt;li&gt;For codes you do want live, set product restrictions, max redemptions, expiry and per-customer limits so a leaked code cannot drain a tier.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your cancellation flow offers a retention discount, keep that promotion code's terms aligned with whatever your email and portal promise.&lt;/p&gt;




&lt;h2&gt;
  
  
  12. Branding, legal and invoices
&lt;/h2&gt;

&lt;p&gt;The boring settings that make you look like a real company and keep you compliant.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Set the statement descriptor so customers recognize the charge on their bank statement and do not file a chargeback out of confusion.&lt;/li&gt;
&lt;li&gt;Set a real support email, and your live terms and privacy URLs, in the public business details. EU sellers also need a reachable imprint.&lt;/li&gt;
&lt;li&gt;Put your VAT ID on invoices, set sensible payment terms and enable PDF invoices and credit notes.&lt;/li&gt;
&lt;li&gt;If you want a branded billing experience, configure a custom domain for Checkout, Payment Links and the Portal. If you do not, customers will see &lt;code&gt;stripe.com&lt;/code&gt; domains, which is fine but worth deciding on rather than discovering.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  13. Test the integration, not just the code
&lt;/h2&gt;

&lt;p&gt;Stripe's test mode and CLI let you exercise the whole path without spending money. Use them on every webhook change.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stripe login

&lt;span class="c"&gt;# forward live test events to your local handler&lt;/span&gt;
stripe listen &lt;span class="nt"&gt;--forward-to&lt;/span&gt; localhost:4242/api/webhooks/stripe

&lt;span class="c"&gt;# fire specific events&lt;/span&gt;
stripe trigger checkout.session.completed
stripe trigger checkout.session.async_payment_succeeded
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep automated tests that assert your handler does the right thing for each event type you subscribe to, including the async success path that is hard to hit by clicking. When you add a new event in code, add it in the Dashboard and add a test in the same change, so the three never drift.&lt;/p&gt;




&lt;h2&gt;
  
  
  A pre-launch checklist
&lt;/h2&gt;

&lt;p&gt;Run this before you take real EU money.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webhooks&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Signature verification on, against the raw body&lt;/li&gt;
&lt;li&gt;[ ] Dashboard subscribed events match the events your code handles, both directions&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;checkout.session.async_payment_succeeded&lt;/code&gt; subscribed and provisioning&lt;/li&gt;
&lt;li&gt;[ ] Handlers are idempotent and return 2xx fast&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Async and Checkout&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;code&gt;checkout.session.completed&lt;/code&gt; guarded on &lt;code&gt;payment_status === 'paid'&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Post-checkout page copy is pending-neutral&lt;/li&gt;
&lt;li&gt;[ ] SEPA-paid test customer gets provisioned and emailed after settlement&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Plans and prices&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Every active price has &lt;code&gt;lookup_key&lt;/code&gt; and &lt;code&gt;metadata.plan&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Plan resolution reads metadata and lookup key, never nicknames&lt;/li&gt;
&lt;li&gt;[ ] Every archived price with active subscriptions resolves to a current plan&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Currency and tax&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Advertised prices reconcile with Checkout amounts per region&lt;/li&gt;
&lt;li&gt;[ ] Automatic tax on, tax codes set per product, behavior consistent per currency&lt;/li&gt;
&lt;li&gt;[ ] VAT ID collection on for B2B, reverse charge verified&lt;/li&gt;
&lt;li&gt;[ ] Tax registration done where required, filing owner assigned&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Portal and dunning&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] One portal config per product line, links routed correctly&lt;/li&gt;
&lt;li&gt;[ ] Downgrade timing set, cross-product downgrades verified live&lt;/li&gt;
&lt;li&gt;[ ] Failure emails on for every method you accept, including bank debit&lt;/li&gt;
&lt;li&gt;[ ] After-retry behavior matches how your app reads subscription status&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Hygiene and security&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;code&gt;Limit customers to 1 subscription&lt;/code&gt; decided&lt;/li&gt;
&lt;li&gt;[ ] No secret keys in client code, restricted keys where possible&lt;/li&gt;
&lt;li&gt;[ ] Secrets stored out of band, rotation runbook exists, 2FA enforced&lt;/li&gt;
&lt;li&gt;[ ] Radar card-testing protection on&lt;/li&gt;
&lt;li&gt;[ ] Test and unused coupons restricted or deleted&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;The pattern under all of it is the same. Stripe is two systems, the Dashboard configuration and your webhook code, and they only work when they agree. Most billing bugs are not bugs in either half. They are a quiet disagreement between the two that no test caught because the test only checked one side.&lt;/p&gt;

&lt;p&gt;Audit both halves together, on a schedule, and bill your European customers with a straight face.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>saas</category>
      <category>payments</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How We Built a CTO-Grade Grafana Dashboard With Codex</title>
      <dc:creator>Alexander Schneider</dc:creator>
      <pubDate>Sun, 31 May 2026 14:57:29 +0000</pubDate>
      <link>https://dev.to/alexanderschneider/how-we-built-a-cto-grade-grafana-dashboard-with-codex-1oc5</link>
      <guid>https://dev.to/alexanderschneider/how-we-built-a-cto-grade-grafana-dashboard-with-codex-1oc5</guid>
      <description>&lt;p&gt;A good dashboard is not a wall of charts. It is an answer to a question.&lt;/p&gt;

&lt;p&gt;For us, the question was simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is production healthy, and if not, where should we look first?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We already had the usual observability ingredients: application metrics, host metrics, container metrics, structured logs, traces, synthetic checks, and database telemetry. The hard part was not collecting more data. The hard part was turning that data into a dashboard that a technical leader could open during a normal day, a deploy, or an incident and understand the state of the system quickly.&lt;/p&gt;

&lt;p&gt;Grafana gave us the observability platform. Codex helped us keep the dashboard honest, small, tested, and aligned with the codebase.&lt;/p&gt;

&lt;p&gt;This is the playbook we ended up with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start With Decisions, Not Charts
&lt;/h2&gt;

&lt;p&gt;The first version of almost every dashboard grows by accumulation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CPU chart&lt;/li&gt;
&lt;li&gt;memory chart&lt;/li&gt;
&lt;li&gt;request rate chart&lt;/li&gt;
&lt;li&gt;latency chart&lt;/li&gt;
&lt;li&gt;error chart&lt;/li&gt;
&lt;li&gt;database chart&lt;/li&gt;
&lt;li&gt;worker chart&lt;/li&gt;
&lt;li&gt;logs chart&lt;/li&gt;
&lt;li&gt;another latency chart&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is useful for exploration, but it is not a good top-level operational view.&lt;/p&gt;

&lt;p&gt;For our CTO dashboard, we started from decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is the public API reachable?&lt;/li&gt;
&lt;li&gt;Are enough API nodes alive to serve traffic redundantly?&lt;/li&gt;
&lt;li&gt;Are workers alive and producing useful data?&lt;/li&gt;
&lt;li&gt;Is PostgreSQL healthy?&lt;/li&gt;
&lt;li&gt;Are alerts firing?&lt;/li&gt;
&lt;li&gt;Is observability itself working?&lt;/li&gt;
&lt;li&gt;If something is slow, is it API, worker, database, logs, or tracing?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only after writing down those questions did we choose panels.&lt;/p&gt;

&lt;p&gt;That one change made the dashboard much smaller.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate Product Health From Observability Coverage
&lt;/h2&gt;

&lt;p&gt;One of the most important design choices was splitting these two ideas:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Product health: is the service working for users?&lt;/li&gt;
&lt;li&gt;Observability coverage: can we see the service clearly?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those are related, but they are not the same.&lt;/p&gt;

&lt;p&gt;If an observability agent stops reporting from one host, that is bad. But it should not automatically make the product look down. Likewise, an API outage should not be hidden because the metrics pipeline is still green.&lt;/p&gt;

&lt;p&gt;So our top-level dashboard has separate areas for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;production health&lt;/li&gt;
&lt;li&gt;observability coverage&lt;/li&gt;
&lt;li&gt;API node readiness&lt;/li&gt;
&lt;li&gt;worker health&lt;/li&gt;
&lt;li&gt;database health&lt;/li&gt;
&lt;li&gt;alerts and drill-down links&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes incidents easier to reason about. A broken monitoring path is visible, but it does not masquerade as a product outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Manage Grafana Assets Like Code
&lt;/h2&gt;

&lt;p&gt;The biggest quality jump came when we stopped treating Grafana as a browser-only editing surface.&lt;/p&gt;

&lt;p&gt;Our dashboards, alert rules, synthetic checks, and collector configs live in git:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ops/grafana/
  dashboards/
  alerts/
  synthetics/
  alloy/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives us normal engineering controls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pull requests&lt;/li&gt;
&lt;li&gt;code review&lt;/li&gt;
&lt;li&gt;CI checks&lt;/li&gt;
&lt;li&gt;repeatable sync&lt;/li&gt;
&lt;li&gt;history&lt;/li&gt;
&lt;li&gt;rollback&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dashboard JSON is not pretty, but it is still production behavior. If it decides what engineers see during incidents, it deserves the same review discipline as application code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grafana's MCP Is Actually Useful
&lt;/h2&gt;

&lt;p&gt;One thing that surprised us in a good way: &lt;a href="https://github.com/grafana/mcp-grafana" rel="noopener noreferrer"&gt;Grafana's MCP integration&lt;/a&gt; was not a toy.&lt;/p&gt;

&lt;p&gt;It was practical enough to help with real dashboard work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;finding dashboards and panels&lt;/li&gt;
&lt;li&gt;inspecting datasource-backed queries&lt;/li&gt;
&lt;li&gt;checking alerting and dashboard structure&lt;/li&gt;
&lt;li&gt;moving between Grafana context and repository context&lt;/li&gt;
&lt;li&gt;turning operational questions into concrete dashboard changes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That matters because AI assistance gets much better when it can inspect the actual observability system instead of guessing from exported JSON alone. The Grafana MCP made Codex feel connected to the running operational surface, while the repository still stayed the source of truth.&lt;/p&gt;

&lt;p&gt;We also tried similar MCP-style integrations from other observability vendors. In our evaluation, Grafana's was the most useful and reliable for day-to-day engineering work. Some alternatives, including New Relic's, did not feel mature enough for our workflow yet, so I would not recommend adopting them as the primary AI-observability interface today.&lt;/p&gt;

&lt;p&gt;That may change, but if I were setting this up again now, I would start with Grafana.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use Grafana Alloy as the Collection Layer
&lt;/h2&gt;

&lt;p&gt;Grafana Alloy became the edge collector for our production telemetry.&lt;/p&gt;

&lt;p&gt;In broad terms, the pipeline looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;application metrics
host metrics
container metrics
database metrics
structured logs
OTLP traces
synthetic checks
        |
        v
Grafana Alloy
        |
        v
Grafana Cloud: Prometheus, Loki, Tempo, dashboards, alerts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alloy lets us keep the collection profile explicit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;scrape host and container metrics&lt;/li&gt;
&lt;li&gt;collect textfile metrics for custom worker state&lt;/li&gt;
&lt;li&gt;forward structured logs&lt;/li&gt;
&lt;li&gt;receive OTLP traces&lt;/li&gt;
&lt;li&gt;remote-write metrics&lt;/li&gt;
&lt;li&gt;apply relabeling and cardinality controls before data leaves the host&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important lesson: treat the collector config as part of the product. It defines what you can and cannot see during an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metrics For SLIs, Logs And Traces For Investigation
&lt;/h2&gt;

&lt;p&gt;We try not to build core service-level indicators from log parsing.&lt;/p&gt;

&lt;p&gt;For top-level API health, metrics are the right source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rate(api_requests_total[5m])
rate(api_request_duration_seconds_bucket[5m])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Logs are still critical, but they are better for answering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which request failed?&lt;/li&gt;
&lt;li&gt;Which worker cycle failed?&lt;/li&gt;
&lt;li&gt;Which trace ID connects API, worker, and database behavior?&lt;/li&gt;
&lt;li&gt;What did the application say at the time?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Traces are the next step after metrics and logs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which route was slow?&lt;/li&gt;
&lt;li&gt;Where did time go?&lt;/li&gt;
&lt;li&gt;Did the database dominate the request?&lt;/li&gt;
&lt;li&gt;Was the problem isolated to one worker or one dependency?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The top-level dashboard should point to those tools. It should not try to replace them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add Domain Metrics, Not Just Infrastructure Metrics
&lt;/h2&gt;

&lt;p&gt;Infrastructure metrics tell you whether machines are alive.&lt;/p&gt;

&lt;p&gt;They do not always tell you whether the business process is useful.&lt;/p&gt;

&lt;p&gt;For worker systems, we added domain-specific metrics such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;worker alive&lt;/li&gt;
&lt;li&gt;heartbeat age&lt;/li&gt;
&lt;li&gt;last cycle status&lt;/li&gt;
&lt;li&gt;last cycle duration&lt;/li&gt;
&lt;li&gt;items produced in the latest cycle&lt;/li&gt;
&lt;li&gt;items observed over recent windows&lt;/li&gt;
&lt;li&gt;consecutive failures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This matters because a worker can be technically alive and still produce no useful output.&lt;/p&gt;

&lt;p&gt;That distinction changed the quality of our alerts. We could alert on:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;the worker is alive, but it has produced no useful data for a while&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is much better than only alerting when the process is dead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Be Ruthless About Metric Semantics
&lt;/h2&gt;

&lt;p&gt;One small example: we had a non-scraper background worker showing up in a "mentions produced" panel with a value of zero.&lt;/p&gt;

&lt;p&gt;Technically, the metric existed.&lt;/p&gt;

&lt;p&gt;Operationally, it was noise.&lt;/p&gt;

&lt;p&gt;The fix was not to hide the worker everywhere. Its status and duration still mattered. The correct fix was to exclude it only from panels where "mentions" was the business meaning.&lt;/p&gt;

&lt;p&gt;That is the kind of dashboard maintenance Codex is very good at:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;inspect the dashboard JSON&lt;/li&gt;
&lt;li&gt;find the PromQL targets&lt;/li&gt;
&lt;li&gt;understand which panels use which metric&lt;/li&gt;
&lt;li&gt;make the smallest scoped change&lt;/li&gt;
&lt;li&gt;add a regression test&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Small semantic fixes like this are what make dashboards feel trustworthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test The Dashboard
&lt;/h2&gt;

&lt;p&gt;Testing dashboards sounds strange until the first time a dashboard breaks during an incident.&lt;/p&gt;

&lt;p&gt;We test things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dashboard JSON is valid&lt;/li&gt;
&lt;li&gt;dashboard titles and UIDs stay stable&lt;/li&gt;
&lt;li&gt;required panels exist&lt;/li&gt;
&lt;li&gt;important PromQL expressions are still present&lt;/li&gt;
&lt;li&gt;alert rule groups keep stable identifiers&lt;/li&gt;
&lt;li&gt;datasource UIDs are correct&lt;/li&gt;
&lt;li&gt;high-cardinality labels do not leak into metrics or logs&lt;/li&gt;
&lt;li&gt;business panels exclude irrelevant worker types&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tests do not need to render the dashboard. They need to protect the contract.&lt;/p&gt;

&lt;p&gt;Example contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;worker_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;analytics_rollup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;mention_panel_series&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice, the real tests are a little more robust than this, but the idea is simple: encode the intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Codex Helped Most
&lt;/h2&gt;

&lt;p&gt;Codex was useful because it could work across the repo, not just inside one file.&lt;/p&gt;

&lt;p&gt;A dashboard change often touches several places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dashboard JSON&lt;/li&gt;
&lt;li&gt;alert YAML&lt;/li&gt;
&lt;li&gt;collector config&lt;/li&gt;
&lt;li&gt;metric writer script&lt;/li&gt;
&lt;li&gt;tests&lt;/li&gt;
&lt;li&gt;docs&lt;/li&gt;
&lt;li&gt;deployment sync logic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Codex could inspect those relationships and avoid local-only fixes that looked correct but broke the wider system.&lt;/p&gt;

&lt;p&gt;The best workflow was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;state the operational problem in plain language&lt;/li&gt;
&lt;li&gt;ask Codex to inspect the relevant repo paths&lt;/li&gt;
&lt;li&gt;require a small scoped diff&lt;/li&gt;
&lt;li&gt;require tests&lt;/li&gt;
&lt;li&gt;review the PromQL and dashboard semantics like production code&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Codex is not a replacement for operational judgment. It is a very fast assistant for applying that judgment consistently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dashboard Architecture We Recommend
&lt;/h2&gt;

&lt;p&gt;For a production service, I would structure Grafana like this:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. CTO Overview
&lt;/h3&gt;

&lt;p&gt;The top-level dashboard should answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;is production healthy?&lt;/li&gt;
&lt;li&gt;are users affected?&lt;/li&gt;
&lt;li&gt;are alerts firing?&lt;/li&gt;
&lt;li&gt;are enough nodes serving traffic?&lt;/li&gt;
&lt;li&gt;are workers producing useful output?&lt;/li&gt;
&lt;li&gt;is the database healthy?&lt;/li&gt;
&lt;li&gt;is observability coverage intact?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep this dashboard short.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Production Infrastructure
&lt;/h3&gt;

&lt;p&gt;This is where you put:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;host CPU and memory&lt;/li&gt;
&lt;li&gt;container CPU and memory&lt;/li&gt;
&lt;li&gt;disk usage&lt;/li&gt;
&lt;li&gt;process freshness&lt;/li&gt;
&lt;li&gt;container freshness&lt;/li&gt;
&lt;li&gt;agent health&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Database Performance
&lt;/h3&gt;

&lt;p&gt;This is where you put:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;connection usage&lt;/li&gt;
&lt;li&gt;cache hit ratio&lt;/li&gt;
&lt;li&gt;lock pressure&lt;/li&gt;
&lt;li&gt;transaction rate&lt;/li&gt;
&lt;li&gt;temp file churn&lt;/li&gt;
&lt;li&gt;slow query groups&lt;/li&gt;
&lt;li&gt;table pressure&lt;/li&gt;
&lt;li&gt;top query fingerprints&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Tracing
&lt;/h3&gt;

&lt;p&gt;This is where you put:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;accepted spans&lt;/li&gt;
&lt;li&gt;failed exports&lt;/li&gt;
&lt;li&gt;slow API traces&lt;/li&gt;
&lt;li&gt;recent worker traces&lt;/li&gt;
&lt;li&gt;database spans&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Synthetic Monitoring
&lt;/h3&gt;

&lt;p&gt;This is where you put:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;public liveness&lt;/li&gt;
&lt;li&gt;readiness&lt;/li&gt;
&lt;li&gt;deeper health checks&lt;/li&gt;
&lt;li&gt;multi-region latency&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The overview links to the diagnostic dashboards. It does not try to become all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security And Cardinality Rules
&lt;/h2&gt;

&lt;p&gt;A public article should say this plainly: observability can leak data if you are careless.&lt;/p&gt;

&lt;p&gt;Our rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;never put API keys in labels&lt;/li&gt;
&lt;li&gt;never put customer identifiers in labels&lt;/li&gt;
&lt;li&gt;never put raw URLs with query strings in labels&lt;/li&gt;
&lt;li&gt;never put request IDs or trace IDs in Prometheus labels&lt;/li&gt;
&lt;li&gt;keep secrets out of dashboard JSON&lt;/li&gt;
&lt;li&gt;keep collector credentials outside git&lt;/li&gt;
&lt;li&gt;use route templates instead of raw paths&lt;/li&gt;
&lt;li&gt;prefer bounded enum labels&lt;/li&gt;
&lt;li&gt;drop or avoid high-cardinality metrics before remote write&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Logs and traces can contain richer context, but even there you should be deliberate. Query-time parsing is often safer than promoting every field to a label.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Tips
&lt;/h2&gt;

&lt;p&gt;Here are the rules I would reuse on any team.&lt;/p&gt;

&lt;h3&gt;
  
  
  Write The Dashboard Goal First
&lt;/h3&gt;

&lt;p&gt;Before adding panels, write one sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This dashboard exists to help us decide ...&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you cannot finish that sentence, you are probably building a chart collection, not a dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use Boring Names
&lt;/h3&gt;

&lt;p&gt;Panel names should be operationally obvious:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;API 5xx Error Rate&lt;/li&gt;
&lt;li&gt;API Nodes Ready&lt;/li&gt;
&lt;li&gt;Workers Alive&lt;/li&gt;
&lt;li&gt;PostgreSQL Connections&lt;/li&gt;
&lt;li&gt;Worker Heartbeat Age&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Avoid clever names. During incidents, nobody wants to decode poetry.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prefer Fewer Panels
&lt;/h3&gt;

&lt;p&gt;The best overview dashboard is the one people actually use.&lt;/p&gt;

&lt;p&gt;If a panel does not change a decision, move it to a drill-down dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Add Links
&lt;/h3&gt;

&lt;p&gt;Every top-level panel should have an obvious next place to go:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;production overview&lt;/li&gt;
&lt;li&gt;database dashboard&lt;/li&gt;
&lt;li&gt;tracing dashboard&lt;/li&gt;
&lt;li&gt;logs exploration&lt;/li&gt;
&lt;li&gt;runbook&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Test The Intent
&lt;/h3&gt;

&lt;p&gt;Do not only test JSON validity.&lt;/p&gt;

&lt;p&gt;Test the meaning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;this panel uses the API metrics datasource&lt;/li&gt;
&lt;li&gt;this alert has a stable UID&lt;/li&gt;
&lt;li&gt;this worker is excluded from this business metric&lt;/li&gt;
&lt;li&gt;this latency alert has a traffic floor&lt;/li&gt;
&lt;li&gt;this dashboard does not depend on a deprecated metric&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Keep Manual Edits Temporary
&lt;/h3&gt;

&lt;p&gt;Manual Grafana edits are great for exploration. They are not a source of truth.&lt;/p&gt;

&lt;p&gt;Once the panel matters, export it, commit it, review it, and sync it from code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Perfect" Means
&lt;/h2&gt;

&lt;p&gt;The perfect dashboard is not the biggest dashboard.&lt;/p&gt;

&lt;p&gt;It is the dashboard where every panel earns its place.&lt;/p&gt;

&lt;p&gt;For us, that meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;metrics for health&lt;/li&gt;
&lt;li&gt;logs for detail&lt;/li&gt;
&lt;li&gt;traces for causality&lt;/li&gt;
&lt;li&gt;alerts tied to action&lt;/li&gt;
&lt;li&gt;dashboards stored in git&lt;/li&gt;
&lt;li&gt;tests that protect dashboard meaning&lt;/li&gt;
&lt;li&gt;Codex helping us keep changes small and consistent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Grafana made the system visible. Codex helped us make that visibility maintainable. That combination turned observability from a collection of charts into an operational product.&lt;/p&gt;

</description>
      <category>grafana</category>
      <category>observability</category>
      <category>devops</category>
      <category>ai</category>
    </item>
    <item>
      <title>How We Built a CTO-Grade Grafana Dashboard With Codex</title>
      <dc:creator>Alexander Schneider</dc:creator>
      <pubDate>Sun, 31 May 2026 14:57:29 +0000</pubDate>
      <link>https://dev.to/alexanderschneider/how-we-built-a-cto-grade-grafana-dashboard-with-codex-1jda</link>
      <guid>https://dev.to/alexanderschneider/how-we-built-a-cto-grade-grafana-dashboard-with-codex-1jda</guid>
      <description>&lt;p&gt;A good dashboard is not a wall of charts. It is an answer to a question.&lt;/p&gt;

&lt;p&gt;For us, the question was simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is production healthy, and if not, where should we look first?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We already had the usual observability ingredients: application metrics, host metrics, container metrics, structured logs, traces, synthetic checks, and database telemetry. The hard part was not collecting more data. The hard part was turning that data into a dashboard that a technical leader could open during a normal day, a deploy, or an incident and understand the state of the system quickly.&lt;/p&gt;

&lt;p&gt;Grafana gave us the observability platform. Codex helped us keep the dashboard honest, small, tested, and aligned with the codebase.&lt;/p&gt;

&lt;p&gt;This is the playbook we ended up with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start With Decisions, Not Charts
&lt;/h2&gt;

&lt;p&gt;The first version of almost every dashboard grows by accumulation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CPU chart&lt;/li&gt;
&lt;li&gt;memory chart&lt;/li&gt;
&lt;li&gt;request rate chart&lt;/li&gt;
&lt;li&gt;latency chart&lt;/li&gt;
&lt;li&gt;error chart&lt;/li&gt;
&lt;li&gt;database chart&lt;/li&gt;
&lt;li&gt;worker chart&lt;/li&gt;
&lt;li&gt;logs chart&lt;/li&gt;
&lt;li&gt;another latency chart&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is useful for exploration, but it is not a good top-level operational view.&lt;/p&gt;

&lt;p&gt;For our CTO dashboard, we started from decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is the public API reachable?&lt;/li&gt;
&lt;li&gt;Are enough API nodes alive to serve traffic redundantly?&lt;/li&gt;
&lt;li&gt;Are workers alive and producing useful data?&lt;/li&gt;
&lt;li&gt;Is PostgreSQL healthy?&lt;/li&gt;
&lt;li&gt;Are alerts firing?&lt;/li&gt;
&lt;li&gt;Is observability itself working?&lt;/li&gt;
&lt;li&gt;If something is slow, is it API, worker, database, logs, or tracing?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only after writing down those questions did we choose panels.&lt;/p&gt;

&lt;p&gt;That one change made the dashboard much smaller.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate Product Health From Observability Coverage
&lt;/h2&gt;

&lt;p&gt;One of the most important design choices was splitting these two ideas:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Product health: is the service working for users?&lt;/li&gt;
&lt;li&gt;Observability coverage: can we see the service clearly?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those are related, but they are not the same.&lt;/p&gt;

&lt;p&gt;If an observability agent stops reporting from one host, that is bad. But it should not automatically make the product look down. Likewise, an API outage should not be hidden because the metrics pipeline is still green.&lt;/p&gt;

&lt;p&gt;So our top-level dashboard has separate areas for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;production health&lt;/li&gt;
&lt;li&gt;observability coverage&lt;/li&gt;
&lt;li&gt;API node readiness&lt;/li&gt;
&lt;li&gt;worker health&lt;/li&gt;
&lt;li&gt;database health&lt;/li&gt;
&lt;li&gt;alerts and drill-down links&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes incidents easier to reason about. A broken monitoring path is visible, but it does not masquerade as a product outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Manage Grafana Assets Like Code
&lt;/h2&gt;

&lt;p&gt;The biggest quality jump came when we stopped treating Grafana as a browser-only editing surface.&lt;/p&gt;

&lt;p&gt;Our dashboards, alert rules, synthetic checks, and collector configs live in git:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ops/grafana/
  dashboards/
  alerts/
  synthetics/
  alloy/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives us normal engineering controls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pull requests&lt;/li&gt;
&lt;li&gt;code review&lt;/li&gt;
&lt;li&gt;CI checks&lt;/li&gt;
&lt;li&gt;repeatable sync&lt;/li&gt;
&lt;li&gt;history&lt;/li&gt;
&lt;li&gt;rollback&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dashboard JSON is not pretty, but it is still production behavior. If it decides what engineers see during incidents, it deserves the same review discipline as application code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grafana's MCP Is Actually Useful
&lt;/h2&gt;

&lt;p&gt;One thing that surprised us in a good way: &lt;a href="https://github.com/grafana/mcp-grafana" rel="noopener noreferrer"&gt;Grafana's MCP integration&lt;/a&gt; was not a toy.&lt;/p&gt;

&lt;p&gt;It was practical enough to help with real dashboard work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;finding dashboards and panels&lt;/li&gt;
&lt;li&gt;inspecting datasource-backed queries&lt;/li&gt;
&lt;li&gt;checking alerting and dashboard structure&lt;/li&gt;
&lt;li&gt;moving between Grafana context and repository context&lt;/li&gt;
&lt;li&gt;turning operational questions into concrete dashboard changes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That matters because AI assistance gets much better when it can inspect the actual observability system instead of guessing from exported JSON alone. The Grafana MCP made Codex feel connected to the running operational surface, while the repository still stayed the source of truth.&lt;/p&gt;

&lt;p&gt;We also tried similar MCP-style integrations from other observability vendors. In our evaluation, Grafana's was the most useful and reliable for day-to-day engineering work. Some alternatives, including New Relic's, did not feel mature enough for our workflow yet, so I would not recommend adopting them as the primary AI-observability interface today.&lt;/p&gt;

&lt;p&gt;That may change, but if I were setting this up again now, I would start with Grafana.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use Grafana Alloy as the Collection Layer
&lt;/h2&gt;

&lt;p&gt;Grafana Alloy became the edge collector for our production telemetry.&lt;/p&gt;

&lt;p&gt;In broad terms, the pipeline looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;application metrics
host metrics
container metrics
database metrics
structured logs
OTLP traces
synthetic checks
        |
        v
Grafana Alloy
        |
        v
Grafana Cloud: Prometheus, Loki, Tempo, dashboards, alerts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alloy lets us keep the collection profile explicit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;scrape host and container metrics&lt;/li&gt;
&lt;li&gt;collect textfile metrics for custom worker state&lt;/li&gt;
&lt;li&gt;forward structured logs&lt;/li&gt;
&lt;li&gt;receive OTLP traces&lt;/li&gt;
&lt;li&gt;remote-write metrics&lt;/li&gt;
&lt;li&gt;apply relabeling and cardinality controls before data leaves the host&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important lesson: treat the collector config as part of the product. It defines what you can and cannot see during an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metrics For SLIs, Logs And Traces For Investigation
&lt;/h2&gt;

&lt;p&gt;We try not to build core service-level indicators from log parsing.&lt;/p&gt;

&lt;p&gt;For top-level API health, metrics are the right source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rate(api_requests_total[5m])
rate(api_request_duration_seconds_bucket[5m])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Logs are still critical, but they are better for answering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which request failed?&lt;/li&gt;
&lt;li&gt;Which worker cycle failed?&lt;/li&gt;
&lt;li&gt;Which trace ID connects API, worker, and database behavior?&lt;/li&gt;
&lt;li&gt;What did the application say at the time?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Traces are the next step after metrics and logs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which route was slow?&lt;/li&gt;
&lt;li&gt;Where did time go?&lt;/li&gt;
&lt;li&gt;Did the database dominate the request?&lt;/li&gt;
&lt;li&gt;Was the problem isolated to one worker or one dependency?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The top-level dashboard should point to those tools. It should not try to replace them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add Domain Metrics, Not Just Infrastructure Metrics
&lt;/h2&gt;

&lt;p&gt;Infrastructure metrics tell you whether machines are alive.&lt;/p&gt;

&lt;p&gt;They do not always tell you whether the business process is useful.&lt;/p&gt;

&lt;p&gt;For worker systems, we added domain-specific metrics such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;worker alive&lt;/li&gt;
&lt;li&gt;heartbeat age&lt;/li&gt;
&lt;li&gt;last cycle status&lt;/li&gt;
&lt;li&gt;last cycle duration&lt;/li&gt;
&lt;li&gt;items produced in the latest cycle&lt;/li&gt;
&lt;li&gt;items observed over recent windows&lt;/li&gt;
&lt;li&gt;consecutive failures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This matters because a worker can be technically alive and still produce no useful output.&lt;/p&gt;

&lt;p&gt;That distinction changed the quality of our alerts. We could alert on:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;the worker is alive, but it has produced no useful data for a while&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is much better than only alerting when the process is dead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Be Ruthless About Metric Semantics
&lt;/h2&gt;

&lt;p&gt;One small example: we had a non-scraper background worker showing up in a "mentions produced" panel with a value of zero.&lt;/p&gt;

&lt;p&gt;Technically, the metric existed.&lt;/p&gt;

&lt;p&gt;Operationally, it was noise.&lt;/p&gt;

&lt;p&gt;The fix was not to hide the worker everywhere. Its status and duration still mattered. The correct fix was to exclude it only from panels where "mentions" was the business meaning.&lt;/p&gt;

&lt;p&gt;That is the kind of dashboard maintenance Codex is very good at:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;inspect the dashboard JSON&lt;/li&gt;
&lt;li&gt;find the PromQL targets&lt;/li&gt;
&lt;li&gt;understand which panels use which metric&lt;/li&gt;
&lt;li&gt;make the smallest scoped change&lt;/li&gt;
&lt;li&gt;add a regression test&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Small semantic fixes like this are what make dashboards feel trustworthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test The Dashboard
&lt;/h2&gt;

&lt;p&gt;Testing dashboards sounds strange until the first time a dashboard breaks during an incident.&lt;/p&gt;

&lt;p&gt;We test things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dashboard JSON is valid&lt;/li&gt;
&lt;li&gt;dashboard titles and UIDs stay stable&lt;/li&gt;
&lt;li&gt;required panels exist&lt;/li&gt;
&lt;li&gt;important PromQL expressions are still present&lt;/li&gt;
&lt;li&gt;alert rule groups keep stable identifiers&lt;/li&gt;
&lt;li&gt;datasource UIDs are correct&lt;/li&gt;
&lt;li&gt;high-cardinality labels do not leak into metrics or logs&lt;/li&gt;
&lt;li&gt;business panels exclude irrelevant worker types&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tests do not need to render the dashboard. They need to protect the contract.&lt;/p&gt;

&lt;p&gt;Example contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;worker_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;analytics_rollup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;mention_panel_series&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice, the real tests are a little more robust than this, but the idea is simple: encode the intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Codex Helped Most
&lt;/h2&gt;

&lt;p&gt;Codex was useful because it could work across the repo, not just inside one file.&lt;/p&gt;

&lt;p&gt;A dashboard change often touches several places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dashboard JSON&lt;/li&gt;
&lt;li&gt;alert YAML&lt;/li&gt;
&lt;li&gt;collector config&lt;/li&gt;
&lt;li&gt;metric writer script&lt;/li&gt;
&lt;li&gt;tests&lt;/li&gt;
&lt;li&gt;docs&lt;/li&gt;
&lt;li&gt;deployment sync logic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Codex could inspect those relationships and avoid local-only fixes that looked correct but broke the wider system.&lt;/p&gt;

&lt;p&gt;The best workflow was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;state the operational problem in plain language&lt;/li&gt;
&lt;li&gt;ask Codex to inspect the relevant repo paths&lt;/li&gt;
&lt;li&gt;require a small scoped diff&lt;/li&gt;
&lt;li&gt;require tests&lt;/li&gt;
&lt;li&gt;review the PromQL and dashboard semantics like production code&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Codex is not a replacement for operational judgment. It is a very fast assistant for applying that judgment consistently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dashboard Architecture We Recommend
&lt;/h2&gt;

&lt;p&gt;For a production service, I would structure Grafana like this:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. CTO Overview
&lt;/h3&gt;

&lt;p&gt;The top-level dashboard should answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;is production healthy?&lt;/li&gt;
&lt;li&gt;are users affected?&lt;/li&gt;
&lt;li&gt;are alerts firing?&lt;/li&gt;
&lt;li&gt;are enough nodes serving traffic?&lt;/li&gt;
&lt;li&gt;are workers producing useful output?&lt;/li&gt;
&lt;li&gt;is the database healthy?&lt;/li&gt;
&lt;li&gt;is observability coverage intact?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep this dashboard short.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Production Infrastructure
&lt;/h3&gt;

&lt;p&gt;This is where you put:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;host CPU and memory&lt;/li&gt;
&lt;li&gt;container CPU and memory&lt;/li&gt;
&lt;li&gt;disk usage&lt;/li&gt;
&lt;li&gt;process freshness&lt;/li&gt;
&lt;li&gt;container freshness&lt;/li&gt;
&lt;li&gt;agent health&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Database Performance
&lt;/h3&gt;

&lt;p&gt;This is where you put:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;connection usage&lt;/li&gt;
&lt;li&gt;cache hit ratio&lt;/li&gt;
&lt;li&gt;lock pressure&lt;/li&gt;
&lt;li&gt;transaction rate&lt;/li&gt;
&lt;li&gt;temp file churn&lt;/li&gt;
&lt;li&gt;slow query groups&lt;/li&gt;
&lt;li&gt;table pressure&lt;/li&gt;
&lt;li&gt;top query fingerprints&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Tracing
&lt;/h3&gt;

&lt;p&gt;This is where you put:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;accepted spans&lt;/li&gt;
&lt;li&gt;failed exports&lt;/li&gt;
&lt;li&gt;slow API traces&lt;/li&gt;
&lt;li&gt;recent worker traces&lt;/li&gt;
&lt;li&gt;database spans&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Synthetic Monitoring
&lt;/h3&gt;

&lt;p&gt;This is where you put:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;public liveness&lt;/li&gt;
&lt;li&gt;readiness&lt;/li&gt;
&lt;li&gt;deeper health checks&lt;/li&gt;
&lt;li&gt;multi-region latency&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The overview links to the diagnostic dashboards. It does not try to become all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security And Cardinality Rules
&lt;/h2&gt;

&lt;p&gt;A public article should say this plainly: observability can leak data if you are careless.&lt;/p&gt;

&lt;p&gt;Our rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;never put API keys in labels&lt;/li&gt;
&lt;li&gt;never put customer identifiers in labels&lt;/li&gt;
&lt;li&gt;never put raw URLs with query strings in labels&lt;/li&gt;
&lt;li&gt;never put request IDs or trace IDs in Prometheus labels&lt;/li&gt;
&lt;li&gt;keep secrets out of dashboard JSON&lt;/li&gt;
&lt;li&gt;keep collector credentials outside git&lt;/li&gt;
&lt;li&gt;use route templates instead of raw paths&lt;/li&gt;
&lt;li&gt;prefer bounded enum labels&lt;/li&gt;
&lt;li&gt;drop or avoid high-cardinality metrics before remote write&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Logs and traces can contain richer context, but even there you should be deliberate. Query-time parsing is often safer than promoting every field to a label.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Tips
&lt;/h2&gt;

&lt;p&gt;Here are the rules I would reuse on any team.&lt;/p&gt;

&lt;h3&gt;
  
  
  Write The Dashboard Goal First
&lt;/h3&gt;

&lt;p&gt;Before adding panels, write one sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This dashboard exists to help us decide ...&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you cannot finish that sentence, you are probably building a chart collection, not a dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use Boring Names
&lt;/h3&gt;

&lt;p&gt;Panel names should be operationally obvious:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;API 5xx Error Rate&lt;/li&gt;
&lt;li&gt;API Nodes Ready&lt;/li&gt;
&lt;li&gt;Workers Alive&lt;/li&gt;
&lt;li&gt;PostgreSQL Connections&lt;/li&gt;
&lt;li&gt;Worker Heartbeat Age&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Avoid clever names. During incidents, nobody wants to decode poetry.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prefer Fewer Panels
&lt;/h3&gt;

&lt;p&gt;The best overview dashboard is the one people actually use.&lt;/p&gt;

&lt;p&gt;If a panel does not change a decision, move it to a drill-down dashboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Add Links
&lt;/h3&gt;

&lt;p&gt;Every top-level panel should have an obvious next place to go:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;production overview&lt;/li&gt;
&lt;li&gt;database dashboard&lt;/li&gt;
&lt;li&gt;tracing dashboard&lt;/li&gt;
&lt;li&gt;logs exploration&lt;/li&gt;
&lt;li&gt;runbook&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Test The Intent
&lt;/h3&gt;

&lt;p&gt;Do not only test JSON validity.&lt;/p&gt;

&lt;p&gt;Test the meaning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;this panel uses the API metrics datasource&lt;/li&gt;
&lt;li&gt;this alert has a stable UID&lt;/li&gt;
&lt;li&gt;this worker is excluded from this business metric&lt;/li&gt;
&lt;li&gt;this latency alert has a traffic floor&lt;/li&gt;
&lt;li&gt;this dashboard does not depend on a deprecated metric&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Keep Manual Edits Temporary
&lt;/h3&gt;

&lt;p&gt;Manual Grafana edits are great for exploration. They are not a source of truth.&lt;/p&gt;

&lt;p&gt;Once the panel matters, export it, commit it, review it, and sync it from code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Perfect" Means
&lt;/h2&gt;

&lt;p&gt;The perfect dashboard is not the biggest dashboard.&lt;/p&gt;

&lt;p&gt;It is the dashboard where every panel earns its place.&lt;/p&gt;

&lt;p&gt;For us, that meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;metrics for health&lt;/li&gt;
&lt;li&gt;logs for detail&lt;/li&gt;
&lt;li&gt;traces for causality&lt;/li&gt;
&lt;li&gt;alerts tied to action&lt;/li&gt;
&lt;li&gt;dashboards stored in git&lt;/li&gt;
&lt;li&gt;tests that protect dashboard meaning&lt;/li&gt;
&lt;li&gt;Codex helping us keep changes small and consistent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Grafana made the system visible. Codex helped us make that visibility maintainable. That combination turned observability from a collection of charts into an operational product.&lt;/p&gt;

</description>
      <category>grafana</category>
      <category>observability</category>
      <category>devops</category>
      <category>ai</category>
    </item>
    <item>
      <title>How to Add a Sentiment Confirmation Layer to OpenAlgo with Python</title>
      <dc:creator>Alexander Schneider</dc:creator>
      <pubDate>Wed, 18 Mar 2026 06:01:49 +0000</pubDate>
      <link>https://dev.to/alexanderschneider/how-to-add-a-sentiment-confirmation-layer-to-openalgo-with-python-23kn</link>
      <guid>https://dev.to/alexanderschneider/how-to-add-a-sentiment-confirmation-layer-to-openalgo-with-python-23kn</guid>
      <description>&lt;p&gt;Most trading signals answer one question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Is there a setup?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is useful, but often incomplete.&lt;/p&gt;

&lt;p&gt;A breakout can be technically valid and still fail because there is no real&lt;br&gt;
attention behind it. A screener can return ten names that all look similar on&lt;br&gt;
price, while only one of them is actually dominating discussion, news flow, or&lt;br&gt;
prediction-market activity.&lt;/p&gt;

&lt;p&gt;That is the gap I wanted to close with an OpenAlgo integration for the&lt;br&gt;
&lt;a href="https://api.adanos.org/docs" rel="noopener noreferrer"&gt;Adanos Finance Sentiment API&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Adanos tracks stock-level attention and directional bias across:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reddit&lt;/li&gt;
&lt;li&gt;X.com&lt;/li&gt;
&lt;li&gt;financial news&lt;/li&gt;
&lt;li&gt;Polymarket&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead of replacing OpenAlgo, the idea is to use Adanos as a&lt;br&gt;
&lt;strong&gt;confirmation layer&lt;/strong&gt; before execution.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this is useful for traders
&lt;/h2&gt;

&lt;p&gt;OpenAlgo already gives you the execution and automation building blocks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;TradingView webhooks&lt;/li&gt;
&lt;li&gt;ChartInk-driven workflows&lt;/li&gt;
&lt;li&gt;Python strategies&lt;/li&gt;
&lt;li&gt;broker routing and order execution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Adanos adds a second decision layer:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Does this setup also have attention, bullish conviction, and multi-source support right now?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That matters when you want to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;reject weak breakouts&lt;/li&gt;
&lt;li&gt;rank multiple screener hits&lt;/li&gt;
&lt;li&gt;avoid low-attention entries&lt;/li&gt;
&lt;li&gt;combine technical setups with non-price signals&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The integration model that makes sense
&lt;/h2&gt;

&lt;p&gt;The cleanest way to integrate Adanos into OpenAlgo is not as a broker plugin.&lt;/p&gt;

&lt;p&gt;It works much better as an external signal layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TradingView / ChartInk / Python strategy
        -&amp;gt; Adanos sentiment gate
        -&amp;gt; OpenAlgo order execution
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That keeps responsibilities separate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OpenAlgo stays the automation and execution engine&lt;/li&gt;
&lt;li&gt;Adanos stays the sentiment intelligence layer&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What data the gate uses
&lt;/h2&gt;

&lt;p&gt;For each symbol, the gate pulls source-level detail from Adanos and computes a&lt;br&gt;
small composite.&lt;/p&gt;

&lt;p&gt;Useful fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;buzz_score&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bullish_pct&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mentions&lt;/code&gt; or &lt;code&gt;trade_count&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;trend&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Useful derived metrics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;average_buzz&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bullish_avg&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;conviction&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;source_alignment&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gives you trader-friendly filters like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;average_buzz &amp;gt;= 60&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bullish_avg &amp;gt;= 55&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;block symbols where multiple sources are &lt;code&gt;falling&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;prefer aligned sources over wide divergence&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;Here is the basic flow for TradingView:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TradingView alert
  -&amp;gt; Python middleware
  -&amp;gt; Adanos API check
  -&amp;gt; OpenAlgo placeorder / placesmartorder
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same pattern works for ChartInk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ChartInk screener
  -&amp;gt; Python filter
  -&amp;gt; Adanos ranking / sentiment check
  -&amp;gt; OpenAlgo order
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And for native Python strategies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;strategy signal
  -&amp;gt; Adanos source fetch
  -&amp;gt; local composite decision
  -&amp;gt; OpenAlgo execution
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A small Python sentiment gate
&lt;/h2&gt;

&lt;p&gt;This example keeps the execution path untouched and only decides whether an&lt;br&gt;
order should be sent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;signals&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_source_signals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;composite&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_composite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signals&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;composite&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;average_buzz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BLOCK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;composite&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bullish_avg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;55&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BLOCK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;composite&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;falling_sources&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BLOCK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PASS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full working example I built for OpenAlgo does a bit more:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fetches Reddit, X.com, News, and Polymarket signals&lt;/li&gt;
&lt;li&gt;handles nullable or partially missing source data safely&lt;/li&gt;
&lt;li&gt;computes &lt;code&gt;average_buzz&lt;/code&gt;, &lt;code&gt;bullish_avg&lt;/code&gt;, &lt;code&gt;conviction&lt;/code&gt;, and &lt;code&gt;source_alignment&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;supports both &lt;code&gt;placeorder&lt;/code&gt; and &lt;code&gt;placesmartorder&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;defaults to dry-run mode&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Important implementation details
&lt;/h2&gt;

&lt;p&gt;There are a few things worth getting right if you build this seriously.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Do not point alerts directly at OpenAlgo if you want sentiment gating
&lt;/h3&gt;

&lt;p&gt;If your TradingView or ChartInk webhook still points straight to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/api/v1/placeorder&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;or &lt;code&gt;/api/v1/placesmartorder&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;then you are bypassing the sentiment layer entirely.&lt;/p&gt;

&lt;p&gt;The webhook must point to your middleware or filter endpoint first.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Protect the middleware
&lt;/h3&gt;

&lt;p&gt;This is not optional.&lt;/p&gt;

&lt;p&gt;If you expose a public order-forwarding endpoint, it needs inbound verification:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;secret header&lt;/li&gt;
&lt;li&gt;token check&lt;/li&gt;
&lt;li&gt;signature check&lt;/li&gt;
&lt;li&gt;or another equivalent mechanism&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Otherwise anyone who can reach that endpoint can try to trigger trades.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Keep payload contracts explicit
&lt;/h3&gt;

&lt;p&gt;Do not hand-wave the OpenAlgo schema.&lt;/p&gt;

&lt;p&gt;If you forward to &lt;code&gt;placeorder&lt;/code&gt;, keep the documented structure intact:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;apikey&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;strategy&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;symbol&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;action&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;exchange&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;quantity&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pricetype&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;product&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you forward to &lt;code&gt;placesmartorder&lt;/code&gt;, include the additional smart-order fields&lt;br&gt;
that endpoint expects, such as &lt;code&gt;position_size&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Normalize symbol inputs
&lt;/h3&gt;

&lt;p&gt;If your source system sends &lt;code&gt;{stock}&lt;/code&gt; and OpenAlgo expects &lt;code&gt;symbol&lt;/code&gt;, normalize&lt;br&gt;
that explicitly in middleware.&lt;/p&gt;

&lt;p&gt;A lot of integration bugs are just field-name mismatches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coverage matters
&lt;/h2&gt;

&lt;p&gt;Sentiment data is not equally strong for every symbol.&lt;/p&gt;

&lt;p&gt;Before automating anything live, validate the names you actually trade.&lt;/p&gt;

&lt;p&gt;A good workflow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;test a few target symbols manually&lt;/li&gt;
&lt;li&gt;set minimum thresholds for buzz and bullish ratio&lt;/li&gt;
&lt;li&gt;decide what happens on low or partial coverage&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last point matters more than most people think. A robust workflow should&lt;br&gt;
not crash just because one source is unavailable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I still think this is a good integration
&lt;/h2&gt;

&lt;p&gt;I originally proposed this as an upstream OpenAlgo documentation/examples PR.&lt;br&gt;
The maintainers decided not to merge it because third-party sentiment APIs are&lt;br&gt;
outside their current roadmap.&lt;/p&gt;

&lt;p&gt;That is a fair scope decision.&lt;/p&gt;

&lt;p&gt;But the integration pattern itself is still strong.&lt;/p&gt;

&lt;p&gt;For OpenAlgo users, this is useful because it adds something their chart,&lt;br&gt;
screener, or broker connection does not already provide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cross-source attention&lt;/li&gt;
&lt;li&gt;bullish ratio&lt;/li&gt;
&lt;li&gt;trend confirmation&lt;/li&gt;
&lt;li&gt;signal prioritization&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is exactly the kind of external intelligence layer traders can benefit&lt;br&gt;
from without changing their existing execution stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to build this yourself
&lt;/h2&gt;

&lt;p&gt;Useful links:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Adanos API docs: &lt;a href="https://api.adanos.org/docs" rel="noopener noreferrer"&gt;https://api.adanos.org/docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Get an API key: &lt;a href="https://adanos.org/reddit-stock-sentiment#api" rel="noopener noreferrer"&gt;https://adanos.org/reddit-stock-sentiment#api&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OpenAlgo: &lt;a href="https://github.com/marketcalls/openalgo" rel="noopener noreferrer"&gt;https://github.com/marketcalls/openalgo&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start small:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;run a dry-run middleware&lt;/li&gt;
&lt;li&gt;log approved and rejected trades&lt;/li&gt;
&lt;li&gt;compare the gated results vs. your raw signal stream&lt;/li&gt;
&lt;li&gt;only then wire it into live order flow&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is the fastest way to see whether sentiment confirmation improves your own process.&lt;/p&gt;

</description>
      <category>python</category>
      <category>trading</category>
      <category>opensource</category>
      <category>api</category>
    </item>
    <item>
      <title>adanos-cli: Stock &amp; Crypto Sentiment Straight From Your Terminal</title>
      <dc:creator>Alexander Schneider</dc:creator>
      <pubDate>Fri, 06 Mar 2026 08:04:05 +0000</pubDate>
      <link>https://dev.to/alexanderschneider/adanos-cli-stock-crypto-sentiment-straight-from-your-terminal-47j1</link>
      <guid>https://dev.to/alexanderschneider/adanos-cli-stock-crypto-sentiment-straight-from-your-terminal-47j1</guid>
      <description>&lt;p&gt;Retail traders check sentiment by tab-switching between Reddit, X, and news sites. There is no single view. &lt;code&gt;adanos-cli&lt;/code&gt; is that single view.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is adanos-cli?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://adanos.org/finance-sentiment-cli" rel="noopener noreferrer"&gt;adanos-cli&lt;/a&gt; is a Python CLI that queries the Adanos Finance Sentiment API. It aggregates mention counts, buzz scores, and sentiment polarity from four sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reddit&lt;/strong&gt; — 50+ stock and crypto subreddits (r/wallstreetbets, r/stocks, r/cryptocurrency, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X / Twitter&lt;/strong&gt; — FinTwit tweet and reply analysis&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polymarket&lt;/strong&gt; — prediction market prices, liquidity, and trade volume&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Financial News&lt;/strong&gt; — RSS-sourced article sentiment
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;adanos-cli
adanos onboard wizard
adanos stock TSLA &lt;span class="nt"&gt;--days&lt;/span&gt; 7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Interactive Shell
&lt;/h2&gt;

&lt;p&gt;Run &lt;code&gt;adanos&lt;/code&gt; without arguments to enter an interactive shell. Plain text is interpreted as a query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;adanos-cli&amp;gt; How does TSLA look this week?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CLI parses intent, routes to the right endpoints, and returns a formatted report. It handles English and German, &lt;code&gt;$TICKER&lt;/code&gt; syntax, crypto pairs like BTC/ETH, and "vs" comparisons.&lt;/p&gt;

&lt;p&gt;A lightweight NLP module classifies input into nine intent types (stock report, crypto compare, trending scan, briefing, watchlist report, search fallback, etc.) with zero external dependencies.&lt;/p&gt;

&lt;p&gt;Slash commands map directly to CLI features:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;adanos-cli&amp;gt; /scan --asset stocks --style daytrader
adanos-cli&amp;gt; /trending --platform reddit-stocks --dimension sectors
adanos-cli&amp;gt; /briefing --profile investor --days 30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Morning Scan for Day Traders
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adanos scan &lt;span class="nt"&gt;--asset&lt;/span&gt; stocks &lt;span class="nt"&gt;--style&lt;/span&gt; daytrader &lt;span class="nt"&gt;--limit&lt;/span&gt; 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scanner pulls trending data from all four platforms, merges by ticker, and computes a &lt;strong&gt;consensus buzz score&lt;/strong&gt;, &lt;strong&gt;cross-platform sentiment&lt;/strong&gt;, and &lt;strong&gt;confidence rating&lt;/strong&gt; (0-99). Confidence weighs source diversity and mention volume.&lt;/p&gt;

&lt;p&gt;Results carry a directional signal: &lt;code&gt;bullish&lt;/code&gt;, &lt;code&gt;bearish&lt;/code&gt;, &lt;code&gt;neutral&lt;/code&gt;, or &lt;code&gt;hot&lt;/code&gt; (high buzz, unclear direction). Drill into a ticker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adanos stock NVDA &lt;span class="nt"&gt;--days&lt;/span&gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This returns a per-platform breakdown with Reddit mentions, X tweet volume, Polymarket contract prices, and news sentiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Crypto Portfolio Monitoring
&lt;/h2&gt;

&lt;p&gt;Create a watchlist once, query it daily:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adanos watchlist add portfolio &lt;span class="nt"&gt;--asset&lt;/span&gt; crypto &lt;span class="nt"&gt;--symbols&lt;/span&gt; BTC,ETH,SOL,AVAX
adanos watchlist add portfolio &lt;span class="nt"&gt;--asset&lt;/span&gt; stocks &lt;span class="nt"&gt;--symbols&lt;/span&gt; MSTR,COIN
adanos watchlist report portfolio &lt;span class="nt"&gt;--asset&lt;/span&gt; all &lt;span class="nt"&gt;--days&lt;/span&gt; 7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watchlists live in &lt;code&gt;~/.config/adanos-cli/watchlists.json&lt;/code&gt; and plug into the briefing command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adanos briefing &lt;span class="nt"&gt;--from-watchlist&lt;/span&gt; portfolio &lt;span class="nt"&gt;--days&lt;/span&gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Briefings show top movers and sector heat, scoped to your assets. Seven profile presets (&lt;code&gt;starter&lt;/code&gt;, &lt;code&gt;daytrader&lt;/code&gt;, &lt;code&gt;swing&lt;/code&gt;, &lt;code&gt;investor&lt;/code&gt;, &lt;code&gt;crypto&lt;/code&gt;, &lt;code&gt;research&lt;/code&gt;, &lt;code&gt;portfolio&lt;/code&gt;) control focus and depth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Comparisons
&lt;/h2&gt;

&lt;p&gt;Side-by-side across platforms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adanos compare &lt;span class="nt"&gt;--platform&lt;/span&gt; reddit-stocks TSLA,NVDA,AMD &lt;span class="nt"&gt;--days&lt;/span&gt; 7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or natural language:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adanos ask &lt;span class="s2"&gt;"TSLA vs NVDA"&lt;/span&gt;
adanos ask &lt;span class="s2"&gt;"crypto BTC/ETH"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parser detects &lt;code&gt;vs&lt;/code&gt; and &lt;code&gt;/&lt;/code&gt; patterns, resolves them to compare endpoints, and returns a unified table.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI Agent Integration
&lt;/h2&gt;

&lt;p&gt;Every command supports &lt;code&gt;--output json&lt;/code&gt;. Errors go to stderr as structured JSON. stdout always contains parseable output.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adanos &lt;span class="nt"&gt;--output&lt;/span&gt; json capabilities
adanos &lt;span class="nt"&gt;--output&lt;/span&gt; json endpoint list
adanos &lt;span class="nt"&gt;--output&lt;/span&gt; json stock AAPL &lt;span class="nt"&gt;--days&lt;/span&gt; 7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Design choices for automation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exit codes&lt;/strong&gt;: &lt;code&gt;0&lt;/code&gt; success, &lt;code&gt;1&lt;/code&gt; runtime error, &lt;code&gt;2&lt;/code&gt; auth/usage error&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;31 endpoint IDs&lt;/strong&gt; map 1:1 to the OpenAPI spec, callable via &lt;code&gt;adanos endpoint call &amp;lt;id&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;capabilities&lt;/code&gt;&lt;/strong&gt; returns a self-describing JSON manifest (version, commands, auth setup, endpoint count)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cron job piping the daily scan into Slack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adanos &lt;span class="nt"&gt;--output&lt;/span&gt; json scan &lt;span class="nt"&gt;--asset&lt;/span&gt; stocks &lt;span class="nt"&gt;--style&lt;/span&gt; daytrader &lt;span class="nt"&gt;--limit&lt;/span&gt; 5 &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'{text: .rows[:3] | map(.ticker + " " + .signal) | join(", ")}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; @- &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SLACK_WEBHOOK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Research and Discovery
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Top stocks by Reddit mentions today&lt;/span&gt;
adanos trending &lt;span class="nt"&gt;--platform&lt;/span&gt; reddit-stocks &lt;span class="nt"&gt;--dimension&lt;/span&gt; main &lt;span class="nt"&gt;--days&lt;/span&gt; 1 &lt;span class="nt"&gt;--limit&lt;/span&gt; 20

&lt;span class="c"&gt;# Sector-level sentiment&lt;/span&gt;
adanos trending &lt;span class="nt"&gt;--platform&lt;/span&gt; reddit-stocks &lt;span class="nt"&gt;--dimension&lt;/span&gt; sectors

&lt;span class="c"&gt;# Country exposure&lt;/span&gt;
adanos trending &lt;span class="nt"&gt;--platform&lt;/span&gt; news-stocks &lt;span class="nt"&gt;--dimension&lt;/span&gt; countries

&lt;span class="c"&gt;# Search across a platform&lt;/span&gt;
adanos search &lt;span class="nt"&gt;--platform&lt;/span&gt; x-stocks &lt;span class="s2"&gt;"Microsoft"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check data freshness:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adanos health &lt;span class="nt"&gt;--platform&lt;/span&gt; all
adanos stats &lt;span class="nt"&gt;--platform&lt;/span&gt; reddit-stocks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Direct Endpoint Access
&lt;/h2&gt;

&lt;p&gt;The CLI exposes all 31 API endpoints as callable IDs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adanos endpoint list
adanos endpoint call reddit-stocks.trending &lt;span class="nt"&gt;--days&lt;/span&gt; 1 &lt;span class="nt"&gt;--limit&lt;/span&gt; 10
adanos endpoint call x-stocks.stock &lt;span class="nt"&gt;--ticker&lt;/span&gt; AAPL &lt;span class="nt"&gt;--days&lt;/span&gt; 7
adanos endpoint call polymarket-stocks.compare &lt;span class="nt"&gt;--tickers&lt;/span&gt; TSLA,NVDA
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every OpenAPI path is callable with no additional setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Sentiment Engine Works
&lt;/h2&gt;

&lt;p&gt;The API runs a VADER + Twitter-RoBERTa ONNX ensemble:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;VADER&lt;/strong&gt; handles lexicon-based polarity with 55 custom finance terms (earnings language, momentum phrases, meme-stock slang)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twitter-RoBERTa&lt;/strong&gt; (quantized ONNX, 126 MB) covers informal social text that rule-based scoring misses&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scores blend at 40/60 VADER/RoBERTa weight with a ±0.14 neutral threshold.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Buzz Score&lt;/strong&gt; (0-100) rolls mention volume, sentiment strength, upvote quality, subreddit diversity, and trend direction into one number. A soft-cap function compresses the upper range so 85+ requires genuine multi-source attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation &amp;amp; Setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;adanos-cli
&lt;span class="c"&gt;# or&lt;/span&gt;
pipx &lt;span class="nb"&gt;install &lt;/span&gt;adanos-cli

&lt;span class="c"&gt;# Guided setup&lt;/span&gt;
adanos onboard wizard

&lt;span class="c"&gt;# Or set an existing key&lt;/span&gt;
adanos config &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;--api-key&lt;/span&gt; sk_live_xxx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Requires Python 3.10+. Config lives in &lt;code&gt;~/.config/adanos-cli/config.json&lt;/code&gt;. Environment variables (&lt;code&gt;ADANOS_API_KEY&lt;/code&gt;, &lt;code&gt;ADANOS_BASE_URL&lt;/code&gt;) and CLI flags override it.&lt;/p&gt;

&lt;p&gt;Free tier: 250 requests/month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Install: &lt;code&gt;pip install adanos-cli&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://adanos.org/finance-sentiment-cli" rel="noopener noreferrer"&gt;adanos.org/finance-sentiment-cli&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;API: &lt;a href="https://api.adanos.org" rel="noopener noreferrer"&gt;api.adanos.org&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Contact: &lt;a href="mailto:contact@adanos.org"&gt;contact@adanos.org&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>cli</category>
      <category>finance</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Automate Accounting PDFs from Gmail to Google Drive (No Re-Imports, No Duplicates)</title>
      <dc:creator>Alexander Schneider</dc:creator>
      <pubDate>Wed, 11 Feb 2026 12:53:02 +0000</pubDate>
      <link>https://dev.to/alexanderschneider/automate-accounting-pdfs-from-gmail-to-google-drive-no-re-imports-no-duplicates-1am8</link>
      <guid>https://dev.to/alexanderschneider/automate-accounting-pdfs-from-gmail-to-google-drive-no-re-imports-no-duplicates-1am8</guid>
      <description>&lt;p&gt;If you do bookkeeping once a month, you’ve probably lived this loop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;invoices arrive via Gmail (sometimes forwarded, replied-to, or resent)&lt;/li&gt;
&lt;li&gt;you download the PDFs&lt;/li&gt;
&lt;li&gt;you store them in Drive&lt;/li&gt;
&lt;li&gt;you file them during bookkeeping (move/delete/rename)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;and then automation re-imports the same PDFs again&lt;/strong&gt; 😩&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post shows a Google Apps Script setup that:&lt;/p&gt;

&lt;p&gt;✅ scans your entire Gmail (excluding spam/trash)&lt;br&gt;&lt;br&gt;
✅ exports &lt;strong&gt;PDF attachments&lt;/strong&gt; into Google Drive folders by month (&lt;code&gt;YYYY-MM&lt;/code&gt;)&lt;br&gt;&lt;br&gt;
✅ detects duplicates by &lt;strong&gt;content hash (MD5)&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
✅ &lt;strong&gt;never re-imports&lt;/strong&gt; the same email again—even if you move/delete files in Drive&lt;br&gt;&lt;br&gt;
✅ still &lt;strong&gt;labels threads&lt;/strong&gt; in Gmail for visibility (but does not rely on labels for correctness)&lt;/p&gt;


&lt;h2&gt;
  
  
  The core idea
&lt;/h2&gt;

&lt;p&gt;Relying on a Gmail &lt;strong&gt;thread label&lt;/strong&gt; to skip work is tempting, but it breaks this common scenario:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A thread gets labeled “processed”… then later a new reply comes in with a new PDF attachment.&lt;br&gt;&lt;br&gt;
If you skip by label, you miss that PDF.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Instead, we do two things:&lt;/p&gt;

&lt;p&gt;1) &lt;strong&gt;Message-ID tracking&lt;/strong&gt; (stored in &lt;code&gt;PropertiesService&lt;/code&gt;)  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every Gmail message is processed &lt;strong&gt;exactly once&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;This is what prevents re-imports after you manually move/delete files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;2) &lt;strong&gt;MD5 de-duplication&lt;/strong&gt; per month folder  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Forwarded invoices and resent PDFs won’t create duplicates in Drive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We still set a label (&lt;code&gt;pdf-imported&lt;/code&gt;) for visibility, but the automation does &lt;strong&gt;not&lt;/strong&gt; depend on it.&lt;/p&gt;


&lt;h2&gt;
  
  
  What you’ll get in Google Drive
&lt;/h2&gt;

&lt;p&gt;A structure like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;4_Gmail-Files/
  2026-02/
    2026-02-11__MESSAGEID__A0__Invoice_123.pdf
    2026-02-14__MESSAGEID__A0__Receipt.pdf
  2026-03/
    ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can do monthly bookkeeping and manually move/delete files however you like. The importer won’t recreate them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Create a Drive folder
&lt;/h2&gt;

&lt;p&gt;Create a folder in Google Drive (e.g. &lt;code&gt;4_Gmail-Files&lt;/code&gt;) and copy its folder ID from the URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://drive.google.com/drive/folders/&amp;lt;FOLDER_ID&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 2: Add the Apps Script
&lt;/h2&gt;

&lt;p&gt;Go to &lt;strong&gt;script.google.com&lt;/strong&gt; → &lt;strong&gt;New project&lt;/strong&gt; → paste this code into &lt;code&gt;Code.gs&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Replace &lt;code&gt;ROOT_FOLDER_ID&lt;/code&gt; with your own folder ID.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Gmail → PDF attachments → Google Drive monthly folders (YYYY-MM)
 *
 * ✅ scans ALL mail (in:anywhere), excluding spam/trash
 * ✅ catches new replies in old threads via LOOKBACK window
 * ✅ stores PDFs in &amp;lt;ROOT&amp;gt;/&amp;lt;YYYY-MM&amp;gt;/
 * ✅ prevents duplicates:
 *    - message-id tracking (each Gmail message processed once)
 *    - MD5 content de-dupe (identical PDFs per month folder stored once)
 * ✅ Gmail thread label for visibility (not used for skipping)
 */&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ROOT_FOLDER_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PASTE_YOUR_FOLDER_ID_HERE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Lookback window: must be large enough to catch “old thread, new reply”&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LOOKBACK_DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;365&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GMAIL_QUERY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="s2"&gt;`in:anywhere -in:spam -in:trash newer_than:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;LOOKBACK_DAYS&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;d has:attachment filename:pdf`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Label threads where we actually saved at least one PDF (visibility only)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;THREAD_LABEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pdf-imported&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Mark thread as read after saving something?&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MARK_THREAD_AS_READ&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// How many threads to process per run&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_THREADS_PER_RUN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Tracker sizing: large enough so you don’t “forget” processed messages&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_TRACKED_MESSAGES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Must be &amp;gt;= LOOKBACK_DAYS (recommend bigger)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;KEEP_MESSAGE_IDS_DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DriveApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFolderById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ROOT_FOLDER_ID&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;labelObj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;THREAD_LABEL&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;getOrCreateGmailLabel_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;THREAD_LABEL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tracker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;loadMessageTracker_&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;pruneMessageTracker_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;threads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;GmailApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;GMAIL_QUERY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MAX_THREADS_PER_RUN&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;threads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;saveMessageTracker_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// monthKey -&amp;gt; { monthFolder, md5Set }&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;monthCache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;thread&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;threads&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMessages&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;savedAnyInThread&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messageId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

      &lt;span class="c1"&gt;// Prevent re-imports even if files are moved/deleted in Drive&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;messageId&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;msgDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;monthKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;formatMonthKey_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msgDate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;monthFolder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;md5Set&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getMonthContext_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;monthKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;monthCache&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;attachments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttachments&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;includeInlineImages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;attachments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;att&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;attachments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;originalName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;att&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getName&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;`attachment_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.pdf`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isPdf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
          &lt;span class="nx"&gt;att&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContentType&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
          &lt;span class="nx"&gt;originalName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isPdf&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;att&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copyBlob&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;md5&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;md5Hex_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Content de-dupe in the month folder&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;md5Set&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;md5&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;safeName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sanitizeFilename_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;originalName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;datePrefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Utilities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nx"&gt;msgDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getScriptTimeZone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
          &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;yyyy-MM-dd&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;uniqueDriveName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;datePrefix&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;__&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;__A&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;__&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;safeName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="nx"&gt;monthFolder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;setName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uniqueDriveName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;md5Set&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nx"&gt;savedAnyInThread&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="c1"&gt;// Mark this Gmail message as processed (even if it had no PDFs)&lt;/span&gt;
      &lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messageId&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;savedAnyInThread&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;labelObj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;labelObj&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MARK_THREAD_AS_READ&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;markRead&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="nf"&gt;saveMessageTracker_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ===== Tracker (PropertiesService) =====&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadMessageTracker_&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;PropertiesService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserProperties&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PDF_TRACKER_V1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="na"&gt;order&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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="na"&gt;order&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;saveMessageTracker_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;pruneMessageTracker_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;PropertiesService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserProperties&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PDF_TRACKER_V1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pruneMessageTracker_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;KEEP_MESSAGE_IDS_DAYS&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newOrder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ts&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;cutoff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;newOrder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newOrder&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_TRACKED_MESSAGES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;removeCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;MAX_TRACKED_MESSAGES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;removeCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
      &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tracker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;removeCount&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="c1"&gt;// ===== Month context + MD5 =====&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getMonthContext_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rootFolder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;monthKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;monthCache&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;monthCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;monthKey&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;monthCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;monthKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;monthFolder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getOrCreateSubfolder_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rootFolder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;monthKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;md5Set&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;monthFolder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFiles&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasNext&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;md5&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMd5Checksum&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;md5Set&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;md5&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;monthFolder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;md5Set&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nx"&gt;monthCache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;monthKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;md5Hex_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBytes&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Utilities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;computeDigest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Utilities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DigestAlgorithm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MD5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;digest&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ===== Helpers =====&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;formatMonthKey_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dateObj&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="nx"&gt;Utilities&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dateObj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getScriptTimeZone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;yyyy-MM&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getOrCreateSubfolder_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parentFolder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parentFolder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFoldersByName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasNext&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;parentFolder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createFolder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sanitizeFilename_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&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="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\/\:&lt;/span&gt;&lt;span class="sr"&gt;*?"&amp;lt;&amp;gt;|&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;_&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getOrCreateGmailLabel_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;labelName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;GmailApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserLabelByName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;labelName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;GmailApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;labelName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;label&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;h2&gt;
  
  
  Step 3: Authorize the script
&lt;/h2&gt;

&lt;p&gt;In Apps Script:&lt;/p&gt;

&lt;p&gt;1) Select function &lt;code&gt;run&lt;/code&gt;&lt;br&gt;&lt;br&gt;
2) Click &lt;strong&gt;Run&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
3) Accept permissions (Gmail read + Drive access)&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Set a trigger (every 6 hours)
&lt;/h2&gt;

&lt;p&gt;1) Click the &lt;strong&gt;clock icon&lt;/strong&gt; (“Triggers”)&lt;br&gt;&lt;br&gt;
2) Add Trigger:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Function: &lt;code&gt;run&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Event source: &lt;strong&gt;Time-driven&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Type: &lt;strong&gt;Hour timer&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Interval: &lt;strong&gt;Every 6 hours&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why this works for monthly bookkeeping
&lt;/h2&gt;

&lt;p&gt;When you do bookkeeping, you’ll usually move/delete/rename PDFs inside the month folder.&lt;/p&gt;

&lt;p&gt;This automation &lt;strong&gt;doesn’t care&lt;/strong&gt; what happens in Drive afterward.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It remembers processed &lt;strong&gt;Gmail message IDs&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;So the same email is never imported again, even if you remove the stored PDF.&lt;/li&gt;
&lt;li&gt;And it still catches new PDFs posted later in an old thread.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Tuning tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If you get invoices as replies to &lt;em&gt;very old&lt;/em&gt; threads, increase &lt;code&gt;LOOKBACK_DAYS&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Always keep &lt;code&gt;KEEP_MESSAGE_IDS_DAYS &amp;gt;= LOOKBACK_DAYS&lt;/code&gt; (recommended: much bigger).&lt;/li&gt;
&lt;li&gt;If you have high email volume, increase &lt;code&gt;MAX_THREADS_PER_RUN&lt;/code&gt; (and keep an eye on Apps Script quotas).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy automating 👋&lt;/p&gt;

</description>
      <category>googledrive</category>
      <category>accounting</category>
      <category>googlescript</category>
      <category>cloud</category>
    </item>
    <item>
      <title>We Built Free Embeddable Stock Sentiment Widgets — Here's How to Use Them</title>
      <dc:creator>Alexander Schneider</dc:creator>
      <pubDate>Mon, 09 Feb 2026 10:51:22 +0000</pubDate>
      <link>https://dev.to/alexanderschneider/we-built-free-embeddable-stock-sentiment-widgets-heres-how-to-use-them-1hno</link>
      <guid>https://dev.to/alexanderschneider/we-built-free-embeddable-stock-sentiment-widgets-heres-how-to-use-them-1hno</guid>
      <description>&lt;p&gt;We just shipped something we're really excited about: &lt;strong&gt;three embeddable stock sentiment widgets&lt;/strong&gt; that any developer can drop into their site with a single &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag.&lt;/p&gt;

&lt;p&gt;No API key. No framework dependency. No build step. Just HTML.&lt;/p&gt;

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

&lt;p&gt;We run the &lt;a href="https://adanos.org/reddit-stock-sentiment" rel="noopener noreferrer"&gt;Reddit Stock Sentiment API&lt;/a&gt; — it tracks what retail investors are talking about on Reddit and X/Twitter in real-time, scoring stocks by buzz, sentiment, and trend direction.&lt;/p&gt;

&lt;p&gt;The API is great for developers building dashboards and trading tools. But we kept hearing the same request: &lt;strong&gt;"Can I just embed a widget on my blog?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So we built exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Widgets
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Ticker Tape
&lt;/h3&gt;

&lt;p&gt;A scrolling horizontal bar showing trending stocks with sentiment indicators. Think Bloomberg terminal vibes for your website header.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://adanos.org/embed/adanos-widgets.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;adanos-ticker-tape&lt;/span&gt; &lt;span class="na"&gt;theme=&lt;/span&gt;&lt;span class="s"&gt;"dark"&lt;/span&gt; &lt;span class="na"&gt;source=&lt;/span&gt;&lt;span class="s"&gt;"reddit"&lt;/span&gt; &lt;span class="na"&gt;limit=&lt;/span&gt;&lt;span class="s"&gt;"15"&lt;/span&gt; &lt;span class="na"&gt;speed=&lt;/span&gt;&lt;span class="s"&gt;"normal"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/adanos-ticker-tape&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each ticker item shows the stock logo, symbol, buzz score, a colored sentiment dot (green = bullish, red = bearish), and trend direction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attributes:&lt;/strong&gt;&lt;br&gt;
| Attribute | Options | Default |&lt;br&gt;
|-----------|---------|---------|&lt;br&gt;
| &lt;code&gt;theme&lt;/code&gt; | &lt;code&gt;light&lt;/code&gt;, &lt;code&gt;dark&lt;/code&gt; | &lt;code&gt;light&lt;/code&gt; |&lt;br&gt;
| &lt;code&gt;source&lt;/code&gt; | &lt;code&gt;reddit&lt;/code&gt;, &lt;code&gt;x&lt;/code&gt; | &lt;code&gt;reddit&lt;/code&gt; |&lt;br&gt;
| &lt;code&gt;limit&lt;/code&gt; | &lt;code&gt;5&lt;/code&gt;, &lt;code&gt;10&lt;/code&gt;, &lt;code&gt;15&lt;/code&gt;, &lt;code&gt;20&lt;/code&gt; | &lt;code&gt;10&lt;/code&gt; |&lt;br&gt;
| &lt;code&gt;speed&lt;/code&gt; | &lt;code&gt;slow&lt;/code&gt;, &lt;code&gt;normal&lt;/code&gt;, &lt;code&gt;fast&lt;/code&gt; | &lt;code&gt;normal&lt;/code&gt; |&lt;/p&gt;


&lt;h3&gt;
  
  
  2. Top Movers
&lt;/h3&gt;

&lt;p&gt;A ranked leaderboard of the hottest stocks. Shows buzz score, bullish percentage, trend direction, and a 7-day sparkline chart.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://adanos.org/embed/adanos-widgets.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;adanos-top-movers&lt;/span&gt; &lt;span class="na"&gt;theme=&lt;/span&gt;&lt;span class="s"&gt;"light"&lt;/span&gt; &lt;span class="na"&gt;source=&lt;/span&gt;&lt;span class="s"&gt;"reddit"&lt;/span&gt; &lt;span class="na"&gt;limit=&lt;/span&gt;&lt;span class="s"&gt;"10"&lt;/span&gt; &lt;span class="na"&gt;period=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt; &lt;span class="na"&gt;show-logos=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/adanos-top-movers&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The table is fully responsive — company names hide on tablets, sparklines hide on phones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attributes:&lt;/strong&gt;&lt;br&gt;
| Attribute | Options | Default |&lt;br&gt;
|-----------|---------|---------|&lt;br&gt;
| &lt;code&gt;theme&lt;/code&gt; | &lt;code&gt;light&lt;/code&gt;, &lt;code&gt;dark&lt;/code&gt; | &lt;code&gt;light&lt;/code&gt; |&lt;br&gt;
| &lt;code&gt;source&lt;/code&gt; | &lt;code&gt;reddit&lt;/code&gt;, &lt;code&gt;x&lt;/code&gt; | &lt;code&gt;reddit&lt;/code&gt; |&lt;br&gt;
| &lt;code&gt;limit&lt;/code&gt; | &lt;code&gt;5&lt;/code&gt;, &lt;code&gt;10&lt;/code&gt;, &lt;code&gt;15&lt;/code&gt; | &lt;code&gt;10&lt;/code&gt; |&lt;br&gt;
| &lt;code&gt;period&lt;/code&gt; | &lt;code&gt;1&lt;/code&gt;, &lt;code&gt;3&lt;/code&gt;, &lt;code&gt;7&lt;/code&gt;, &lt;code&gt;14&lt;/code&gt;, &lt;code&gt;21&lt;/code&gt;, &lt;code&gt;30&lt;/code&gt; (days) | &lt;code&gt;1&lt;/code&gt; |&lt;br&gt;
| &lt;code&gt;show-logos&lt;/code&gt; | &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;false&lt;/code&gt; | &lt;code&gt;true&lt;/code&gt; |&lt;/p&gt;


&lt;h3&gt;
  
  
  3. Stock Sentiment Card
&lt;/h3&gt;

&lt;p&gt;A deep-dive card for a single stock. Shows buzz score, bullish percentage, mention count, trend, a 7-day sparkline, and an optional AI-generated explanation of why the stock is trending.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://adanos.org/embed/adanos-widgets.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;adanos-stock-sentiment&lt;/span&gt; &lt;span class="na"&gt;ticker=&lt;/span&gt;&lt;span class="s"&gt;"TSLA"&lt;/span&gt; &lt;span class="na"&gt;theme=&lt;/span&gt;&lt;span class="s"&gt;"light"&lt;/span&gt; &lt;span class="na"&gt;source=&lt;/span&gt;&lt;span class="s"&gt;"reddit"&lt;/span&gt; &lt;span class="na"&gt;show-explanation=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/adanos-stock-sentiment&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Perfect for embedding in stock analysis articles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attributes:&lt;/strong&gt;&lt;br&gt;
| Attribute | Options | Default |&lt;br&gt;
|-----------|---------|---------|&lt;br&gt;
| &lt;code&gt;theme&lt;/code&gt; | &lt;code&gt;light&lt;/code&gt;, &lt;code&gt;dark&lt;/code&gt; | &lt;code&gt;light&lt;/code&gt; |&lt;br&gt;
| &lt;code&gt;source&lt;/code&gt; | &lt;code&gt;reddit&lt;/code&gt;, &lt;code&gt;x&lt;/code&gt; | &lt;code&gt;reddit&lt;/code&gt; |&lt;br&gt;
| &lt;code&gt;ticker&lt;/code&gt; | Any stock symbol | &lt;em&gt;(required)&lt;/em&gt; |&lt;br&gt;
| &lt;code&gt;show-explanation&lt;/code&gt; | &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;false&lt;/code&gt; | &lt;code&gt;true&lt;/code&gt; |&lt;/p&gt;


&lt;h2&gt;
  
  
  Technical Decisions
&lt;/h2&gt;

&lt;p&gt;A few things we think fellow devs will appreciate:&lt;/p&gt;
&lt;h3&gt;
  
  
  Standard Web Components
&lt;/h3&gt;

&lt;p&gt;No React. No Vue. No Angular. Just &lt;code&gt;customElements.define()&lt;/code&gt; and Shadow DOM. The widgets work everywhere — static sites, WordPress, Next.js, whatever. If the browser supports Web Components (all modern ones do), it works.&lt;/p&gt;
&lt;h3&gt;
  
  
  Shadow DOM Encapsulation
&lt;/h3&gt;

&lt;p&gt;Every widget uses Shadow DOM, so our styles never leak into your page and your styles never break our widgets. You get clean isolation out of the box.&lt;/p&gt;
&lt;h3&gt;
  
  
  No API Key Required
&lt;/h3&gt;

&lt;p&gt;The widgets call our proxy endpoint which handles authentication server-side. You don't need to sign up, manage keys, or worry about exposing credentials in your frontend code.&lt;/p&gt;
&lt;h3&gt;
  
  
  Built-in Caching
&lt;/h3&gt;

&lt;p&gt;Every widget has an in-memory cache with a 5-minute TTL and request deduplication. Drop five widgets on a page — they share cached responses. No redundant API calls.&lt;/p&gt;
&lt;h3&gt;
  
  
  Zero External Dependencies
&lt;/h3&gt;

&lt;p&gt;The entire widget library is a single vanilla JS file. No npm packages, no bundler required, no polyfills. The font loading (Space Grotesk + JetBrains Mono) is handled internally and only triggers once per page.&lt;/p&gt;
&lt;h2&gt;
  
  
  Real-World Examples
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Financial news site header:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;adanos-ticker-tape&lt;/span&gt; &lt;span class="na"&gt;theme=&lt;/span&gt;&lt;span class="s"&gt;"dark"&lt;/span&gt; &lt;span class="na"&gt;source=&lt;/span&gt;&lt;span class="s"&gt;"reddit"&lt;/span&gt; &lt;span class="na"&gt;limit=&lt;/span&gt;&lt;span class="s"&gt;"20"&lt;/span&gt; &lt;span class="na"&gt;speed=&lt;/span&gt;&lt;span class="s"&gt;"fast"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/adanos-ticker-tape&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Weekly stock roundup blog post:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;adanos-top-movers&lt;/span&gt; &lt;span class="na"&gt;period=&lt;/span&gt;&lt;span class="s"&gt;"7"&lt;/span&gt; &lt;span class="na"&gt;limit=&lt;/span&gt;&lt;span class="s"&gt;"10"&lt;/span&gt; &lt;span class="na"&gt;show-logos=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/adanos-top-movers&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Single stock analysis article:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;adanos-stock-sentiment&lt;/span&gt; &lt;span class="na"&gt;ticker=&lt;/span&gt;&lt;span class="s"&gt;"NVDA"&lt;/span&gt; &lt;span class="na"&gt;show-explanation=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/adanos-stock-sentiment&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Dark-themed trading dashboard:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;adanos-ticker-tape&lt;/span&gt; &lt;span class="na"&gt;theme=&lt;/span&gt;&lt;span class="s"&gt;"dark"&lt;/span&gt; &lt;span class="na"&gt;source=&lt;/span&gt;&lt;span class="s"&gt;"x"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/adanos-ticker-tape&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;adanos-top-movers&lt;/span&gt; &lt;span class="na"&gt;theme=&lt;/span&gt;&lt;span class="s"&gt;"dark"&lt;/span&gt; &lt;span class="na"&gt;source=&lt;/span&gt;&lt;span class="s"&gt;"x"&lt;/span&gt; &lt;span class="na"&gt;period=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/adanos-top-movers&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;adanos-stock-sentiment&lt;/span&gt; &lt;span class="na"&gt;ticker=&lt;/span&gt;&lt;span class="s"&gt;"AAPL"&lt;/span&gt; &lt;span class="na"&gt;theme=&lt;/span&gt;&lt;span class="s"&gt;"dark"&lt;/span&gt; &lt;span class="na"&gt;source=&lt;/span&gt;&lt;span class="s"&gt;"x"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/adanos-stock-sentiment&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Try the Live Configurator
&lt;/h2&gt;

&lt;p&gt;We built an interactive configurator where you can tweak every attribute, see a live preview, and copy the embed code:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://adanos.org/widgets" rel="noopener noreferrer"&gt;adanos.org/widgets&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Change the theme, data source, number of tickers, time period — the preview updates instantly and the embed code regenerates automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;We're working on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Custom color theming via CSS custom properties&lt;/li&gt;
&lt;li&gt;Webhook-triggered updates for real-time push data&lt;/li&gt;
&lt;li&gt;More data sources beyond Reddit and X&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have ideas or feature requests, drop a comment below or reach out at &lt;a href="https://adanos.org/contact" rel="noopener noreferrer"&gt;adanos.org/contact&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built by &lt;a href="https://adanos.org" rel="noopener noreferrer"&gt;Adanos Software&lt;/a&gt; — we build trading intelligence tools for retail investors.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webcomponents</category>
      <category>javascript</category>
      <category>fintech</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I built a Python script that alerts me when Reddit is hyping a stock</title>
      <dc:creator>Alexander Schneider</dc:creator>
      <pubDate>Fri, 06 Feb 2026 10:14:11 +0000</pubDate>
      <link>https://dev.to/alexanderschneider/i-built-a-python-script-that-alerts-me-when-reddit-is-hyping-a-stock-hp4</link>
      <guid>https://dev.to/alexanderschneider/i-built-a-python-script-that-alerts-me-when-reddit-is-hyping-a-stock-hp4</guid>
      <description>&lt;p&gt;GameStop went from $20 to $480. AMC from $2 to $72. Palantir doubled in a week.&lt;/p&gt;

&lt;p&gt;Every single one started the same way: a handful of Reddit posts in r/wallstreetbets that snowballed into thousands of mentions within hours. By the time CNBC reported on it, the move was already happening.&lt;/p&gt;

&lt;p&gt;The pattern is always the same:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A stock goes from ~5 mentions/day to 50+ mentions in a few hours&lt;/li&gt;
&lt;li&gt;Sentiment flips heavily bullish (70%+ positive)&lt;/li&gt;
&lt;li&gt;It spreads from one subreddit to multiple communities&lt;/li&gt;
&lt;li&gt;The buzz score explodes&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What if you could detect step 1 automatically and get a Slack message before step 4 happens?&lt;/p&gt;

&lt;p&gt;That's what we're building.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;

&lt;p&gt;A Python script that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Checks Reddit sentiment data every hour&lt;/li&gt;
&lt;li&gt;Detects stocks with unusual buzz spikes (momentum)&lt;/li&gt;
&lt;li&gt;Filters for high bullish sentiment (not just noise)&lt;/li&gt;
&lt;li&gt;Sends you a Slack/Discord/email alert&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No machine learning. No complex setup. Just API calls and simple thresholds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;requests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll need a free API key from the &lt;a href="https://adanos.org/reddit-stock-sentiment#api" rel="noopener noreferrer"&gt;Reddit Stock Sentiment API&lt;/a&gt; (250 requests/month, takes 30 seconds to sign up).&lt;/p&gt;

&lt;h2&gt;
  
  
  The detector — 40 lines of Python
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# reddit_hype_alert.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;

&lt;span class="n"&gt;API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your_api_key_here&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.adanos.org/reddit/stocks/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;HEADERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-API-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# --- Thresholds (tune these to your preference) ---
&lt;/span&gt;&lt;span class="n"&gt;BUZZ_MIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;            &lt;span class="c1"&gt;# Minimum buzz score to consider
&lt;/span&gt;&lt;span class="n"&gt;BULLISH_MIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;55&lt;/span&gt;         &lt;span class="c1"&gt;# Minimum bullish percentage
&lt;/span&gt;&lt;span class="n"&gt;TREND_FILTER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rising&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# Only alert on rising stocks
&lt;/span&gt;&lt;span class="n"&gt;SPREAD_MIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;           &lt;span class="c1"&gt;# Mentioned in at least N subreddits
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_trending&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Fetch top 50 trending stocks from the last 24 hours.&lt;/span&gt;&lt;span class="sh"&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;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/trending&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&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="nf"&gt;raise_for_status&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;detect_hype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stocks&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Filter for stocks showing hype signals.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;alerts&lt;/span&gt; &lt;span class="o"&gt;=&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;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stocks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;buzz_score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;BUZZ_MIN&lt;/span&gt;
            &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bullish_pct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;BULLISH_MIN&lt;/span&gt;
            &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;TREND_FILTER&lt;/span&gt;
            &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subreddit_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;SPREAD_MIN&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&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;alerts&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;format_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Format a single stock alert.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🚨 $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ticker&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; — &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;company_name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Unknown&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   Buzz: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;buzz_score&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/100 ↑ &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;trend&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   Mentions: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;mentions&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; across &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;subreddit_count&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; subreddits&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   Sentiment: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bullish_pct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;% bullish&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# --- Run ---
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;M&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;] Scanning Reddit trending stocks...&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;get_trending&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;hype_stocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;detect_hype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stocks&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;hype_stocks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;🔥 &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hype_stocks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; stocks showing hype signals:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&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;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hype_stocks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;format_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No hype signals detected right now.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python reddit_hype_alert.py

[14:30] Scanning Reddit trending stocks...

🔥 3 stocks showing hype signals:

🚨 $NVDA — NVIDIA Corporation
   Buzz: 92.3/100 ↑ rising
   Mentions: 487 across 12 subreddits
   Sentiment: 72% bullish

🚨 $PLTR — Palantir Technologies Inc.
   Buzz: 78.1/100 ↑ rising
   Mentions: 198 across 8 subreddits
   Sentiment: 68% bullish

🚨 $RKLB — Rocket Lab USA, Inc.
   Buzz: 54.2/100 ↑ rising
   Mentions: 67 across 5 subreddits
   Sentiment: 81% bullish
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That third one — $RKLB with 67 mentions but 81% bullish across 5 subreddits — that's exactly the kind of early signal you want to catch. It's not yet on CNBC, but Reddit is already talking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add Slack notifications
&lt;/h2&gt;

&lt;p&gt;Turn it into a real alert system with one function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="n"&gt;SLACK_WEBHOOK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SLACK_WEBHOOK_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_slack_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hype_stocks&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Send hype alerts to Slack.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;SLACK_WEBHOOK&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;blocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;header&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plain_text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🔥 &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hype_stocks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; Reddit Hype Alert&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hype_stocks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&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;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hype_stocks&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;section&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mrkdwn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*$&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ticker&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;* — &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;company_name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Buzz `&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;buzz_score&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;` · &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;mentions&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; mentions · &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bullish_pct&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;% bullish · &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;subreddit_count&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;?&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; subreddits&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;}})&lt;/span&gt;

    &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SLACK_WEBHOOK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blocks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;# Update the run section:
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;hype_stocks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;send_slack_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hype_stocks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Want an AI explanation of WHY it's trending?
&lt;/h2&gt;

&lt;p&gt;The API has an &lt;code&gt;/explain&lt;/code&gt; endpoint that uses an LLM to summarize what Reddit is actually saying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_explanation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Get AI-generated explanation of why a stock is trending.&lt;/span&gt;&lt;span class="sh"&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;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/stock/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ticker&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/explain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&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="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;200&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;explanation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="c1"&gt;# Add to your alert:
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hype_stocks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;explanation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_explanation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticker&lt;/span&gt;&lt;span class="sh"&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;explanation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;   💡 Why: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;explanation&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🚨 $RKLB — Rocket Lab USA, Inc.
   Buzz: 54.2/100 ↑ rising
   Mentions: 67 across 5 subreddits
   Sentiment: 81% bullish
   💡 Why: Rocket Lab trending after successful Neutron rocket test and new
   NASA contract announcement. Retail investors bullish on space sector growth.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you're not just seeing &lt;em&gt;that&lt;/em&gt; something is trending — you know &lt;em&gt;why&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it every hour with cron
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Edit crontab&lt;/span&gt;
crontab &lt;span class="nt"&gt;-e&lt;/span&gt;

&lt;span class="c"&gt;# Add this line (runs every hour during market hours, Mon-Fri)&lt;/span&gt;
0 9-16 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; 1-5 &lt;span class="nb"&gt;cd&lt;/span&gt; /path/to/script &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; python reddit_hype_alert.py &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; alerts.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or if you prefer GitHub Actions, Docker, or a cloud function — the script is stateless, so it runs anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tuning the thresholds
&lt;/h2&gt;

&lt;p&gt;The default thresholds are conservative. Here's how to adjust them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Conservative&lt;/th&gt;
&lt;th&gt;Aggressive&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BUZZ_MIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;Lower = more alerts, earlier signals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BULLISH_MIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;Lower = includes mixed-sentiment stocks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SPREAD_MIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Lower = catches single-subreddit hype&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TREND_FILTER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"rising"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Remove filter&lt;/td&gt;
&lt;td&gt;Also catches &lt;code&gt;"stable"&lt;/code&gt; momentum&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Start conservative. If you're getting 0-1 alerts per day, lower the thresholds. If you're getting 10+, raise them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd build next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cross-platform validation&lt;/strong&gt;: Check if the same stock is also trending on &lt;a href="https://adanos.org/x-stock-sentiment" rel="noopener noreferrer"&gt;X/Twitter&lt;/a&gt;. Stocks hyped on &lt;em&gt;both&lt;/em&gt; platforms have stronger signals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Historical backtesting&lt;/strong&gt;: Use the 90-day history (paid tier) to backtest which threshold combos would have caught past rallies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Portfolio watchlist&lt;/strong&gt;: Only alert on stocks you already own or are watching, using the &lt;code&gt;/v1/stock/{ticker}&lt;/code&gt; endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discord bot&lt;/strong&gt;: Same logic, but posts to a Discord channel for your trading group.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Full code
&lt;/h2&gt;

&lt;p&gt;The complete script is ~60 lines. It uses 2 API calls per run (trending + explain for each alert), so the free tier (250 requests/month) easily covers running it several times per day.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://adanos.org/reddit-stock-sentiment" rel="noopener noreferrer"&gt;Reddit Stock Sentiment API&lt;/a&gt; — sign up for a free key&lt;/li&gt;
&lt;li&gt;&lt;a href="https://api.adanos.org/docs" rel="noopener noreferrer"&gt;Full API docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://adanos.org/buzzscore-whitepaper.pdf" rel="noopener noreferrer"&gt;BuzzScore Whitepaper&lt;/a&gt; — how the 0-100 score works under the hood&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Built by &lt;a href="https://adanos.org" rel="noopener noreferrer"&gt;Adanos Software&lt;/a&gt; in Berlin. The API tracks 12,000+ tickers across 50+ subreddits, updated hourly.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>reddit</category>
      <category>socialmedia</category>
    </item>
    <item>
      <title>How to Host Your Own n8n Automation Server on Hetzner Cloud for Less Than $6/Month</title>
      <dc:creator>Alexander Schneider</dc:creator>
      <pubDate>Fri, 18 Jul 2025 07:00:17 +0000</pubDate>
      <link>https://dev.to/alexanderschneider/how-to-host-your-own-n8n-automation-server-on-hetzner-cloud-for-less-than-6month-149d</link>
      <guid>https://dev.to/alexanderschneider/how-to-host-your-own-n8n-automation-server-on-hetzner-cloud-for-less-than-6month-149d</guid>
      <description>&lt;p&gt;If you're looking for a powerful automation platform that won't break the bank, self-hosting n8n on Hetzner Cloud might be the perfect solution. In this guide, I'll walk you through how we set up a production-ready n8n instance with enterprise-grade security for just ~$6.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Self-Host n8n?
&lt;/h2&gt;

&lt;p&gt;n8n is an open-source workflow automation tool that rivals expensive solutions like Zapier or Make. By self-hosting, you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Complete data privacy and control&lt;/li&gt;
&lt;li&gt;No workflow execution limits&lt;/li&gt;
&lt;li&gt;Custom integrations and code&lt;/li&gt;
&lt;li&gt;Full API access&lt;/li&gt;
&lt;li&gt;Cost-effective scaling&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Setup: What We're Building
&lt;/h2&gt;

&lt;p&gt;Here's what we'll deploy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Server&lt;/strong&gt;: Hetzner Cloud CX22 (2 vCPU, 4GB RAM)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Location&lt;/strong&gt;: Falkenstein, Germany&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Services&lt;/strong&gt;: n8n (Docker), Caddy (reverse proxy with automatic SSL)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security&lt;/strong&gt;: UFW firewall, Fail2ban, audit logging, automated updates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost&lt;/strong&gt;: ~$6 / month&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Creating the Hetzner Cloud Server
&lt;/h2&gt;

&lt;p&gt;First, create a Hetzner Cloud account and set up a new server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Using Hetzner CLI (optional)&lt;/span&gt;
hcloud server create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; n8n-server &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt; cx22 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt; ubuntu-22.04 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt; fsn1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ssh-key&lt;/span&gt; ~/.ssh/id_rsa.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pro tip&lt;/strong&gt;: The CX22 instance type offers the best price-performance ratio for n8n. With 2 vCPUs and 4GB RAM, it can handle substantial workflows without issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Initial Server Security Hardening
&lt;/h2&gt;

&lt;p&gt;Security first! Here's our hardening checklist:&lt;/p&gt;

&lt;h3&gt;
  
  
  Create a non-root user
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# As root&lt;/span&gt;
adduser admin
usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;admin
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /home/admin/.ssh
&lt;span class="nb"&gt;cp&lt;/span&gt; ~/.ssh/authorized_keys /home/admin/.ssh/
&lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; admin:admin /home/admin/.ssh
&lt;span class="nb"&gt;chmod &lt;/span&gt;700 /home/admin/.ssh
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 /home/admin/.ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Disable root login and password authentication
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Edit SSH config&lt;/span&gt;
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/PermitRootLogin yes/PermitRootLogin no/'&lt;/span&gt; /etc/ssh/sshd_config
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/#PasswordAuthentication yes/PasswordAuthentication no/'&lt;/span&gt; /etc/ssh/sshd_config
systemctl restart sshd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Set up UFW firewall
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nb"&gt;enable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Install and configure Fail2ban
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; fail2ban
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/fail2ban/jail.local &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
[DEFAULT]
bantime = 2h
maxretry = 3
findtime = 10m

[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;systemctl restart fail2ban
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Enable automatic security updates
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; unattended-upgrades
dpkg-reconfigure &lt;span class="nt"&gt;-plow&lt;/span&gt; unattended-upgrades
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Set up audit logging
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; auditd
auditctl &lt;span class="nt"&gt;-w&lt;/span&gt; /etc/passwd &lt;span class="nt"&gt;-p&lt;/span&gt; wa &lt;span class="nt"&gt;-k&lt;/span&gt; passwd_changes
auditctl &lt;span class="nt"&gt;-w&lt;/span&gt; /etc/shadow &lt;span class="nt"&gt;-p&lt;/span&gt; wa &lt;span class="nt"&gt;-k&lt;/span&gt; shadow_changes
auditctl &lt;span class="nt"&gt;-w&lt;/span&gt; /var/log/auth.log &lt;span class="nt"&gt;-p&lt;/span&gt; wa &lt;span class="nt"&gt;-k&lt;/span&gt; auth_log
service auditd restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Installing Docker and Docker Compose
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install Docker&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://get.docker.com &lt;span class="nt"&gt;-o&lt;/span&gt; get-docker.sh
sh get-docker.sh

&lt;span class="c"&gt;# Install Docker Compose&lt;/span&gt;
apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker-compose-plugin

&lt;span class="c"&gt;# Verify installation&lt;/span&gt;
docker &lt;span class="nt"&gt;--version&lt;/span&gt;
docker compose version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Setting Up n8n with Docker Compose
&lt;/h2&gt;

&lt;p&gt;Create the n8n directory and docker-compose file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /root/n8n
&lt;span class="nb"&gt;cd&lt;/span&gt; /root/n8n
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a secure &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.8'&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;n8n&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;n8nio/n8n&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;n8n&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:5678:5678"&lt;/span&gt;  &lt;span class="c1"&gt;# Only accessible locally&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;N8N_HOST=n8n.yourdomain.com&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;N8N_PORT=5678&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;N8N_PROTOCOL=https&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;WEBHOOK_URL=https://n8n.yourdomain.com/&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GENERIC_TIMEZONE=Europe/Berlin&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;n8n_data:/home/node/.n8n&lt;/span&gt;
    &lt;span class="na"&gt;security_opt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;no-new-privileges:true&lt;/span&gt;
    &lt;span class="na"&gt;read_only&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;tmpfs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/tmp&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/home/node/.n8n/cache&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;n8n_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Security insights&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Binding to &lt;code&gt;127.0.0.1&lt;/code&gt; ensures n8n is only accessible through our reverse proxy&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;read_only: true&lt;/code&gt; prevents container filesystem modifications&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;no-new-privileges&lt;/code&gt; prevents privilege escalation&lt;/li&gt;
&lt;li&gt;Using tmpfs for temporary files improves security and performance&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 5: Setting Up Caddy as Reverse Proxy
&lt;/h2&gt;

&lt;p&gt;Caddy provides automatic HTTPS certificates and is much simpler than nginx:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install Caddy&lt;/span&gt;
apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; debian-keyring debian-archive-keyring apt-transport-https
curl &lt;span class="nt"&gt;-1sLf&lt;/span&gt; &lt;span class="s1"&gt;'https://dl.cloudsmith.io/public/caddy/stable/gpg.key'&lt;/span&gt; | gpg &lt;span class="nt"&gt;--dearmor&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl &lt;span class="nt"&gt;-1sLf&lt;/span&gt; &lt;span class="s1"&gt;'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt'&lt;/span&gt; | &lt;span class="nb"&gt;tee&lt;/span&gt; /etc/apt/sources.list.d/caddy-stable.list
apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Configure Caddy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/caddy/Caddyfile &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
n8n.yourdomain.com {
    reverse_proxy localhost:5678 {
        header_up X-Real-IP {remote}
        header_up X-Forwarded-For {remote}
        header_up X-Forwarded-Proto {scheme}
    }

    # Security headers
    header {
        X-Content-Type-Options nosniff
        X-Frame-Options DENY
        X-XSS-Protection "1; mode=block"
        Referrer-Policy strict-origin-when-cross-origin
        -Server
    }

    # Enable compression
    encode gzip
}
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;systemctl restart caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 6: Starting n8n
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /root/n8n
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Visit &lt;code&gt;https://n8n.yourdomain.com&lt;/code&gt; and complete the initial setup!&lt;/p&gt;

&lt;h2&gt;
  
  
  Advanced Security Measures
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Implement IP-based access control
&lt;/h3&gt;

&lt;p&gt;If you have a static IP, limit access to n8n:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;n8n.yourdomain.com {
    @allowed {
        remote_ip 1.2.3.4 192.168.0.0/16
    }
    handle @allowed {
        reverse_proxy localhost:5678
    }
    handle {
        respond "Access Denied" 403
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Set up Fail2ban for n8n
&lt;/h3&gt;

&lt;p&gt;Create a custom filter for failed n8n login attempts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/fail2ban/filter.d/n8n-auth.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
[Definition]
failregex = ^.* 401 .* "POST /rest/auth/login.*" .*$
ignoreregex =
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# Add to jail.local&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /etc/fail2ban/jail.local &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'

[n8n-auth]
enabled = true
port = http,https
filter = n8n-auth
logpath = /var/log/caddy/access.log
maxretry = 5
bantime = 1h
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Regular backups
&lt;/h3&gt;

&lt;p&gt;Create a backup script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# /root/backup-n8n.sh&lt;/span&gt;
&lt;span class="nv"&gt;BACKUP_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/root/backups"&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; /root/n8n/docker-compose.yml down
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; n8n_data:/data &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt;:/backup alpine &lt;span class="nb"&gt;tar &lt;/span&gt;czf /backup/n8n-backup-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d&lt;span class="si"&gt;)&lt;/span&gt;.tar.gz &lt;span class="nt"&gt;-C&lt;/span&gt; /data &lt;span class="nb"&gt;.&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; /root/n8n/docker-compose.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;span class="c"&gt;# Keep only last 7 backups&lt;/span&gt;
find &lt;span class="nv"&gt;$BACKUP_DIR&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"n8n-backup-*.tar.gz"&lt;/span&gt; &lt;span class="nt"&gt;-mtime&lt;/span&gt; +7 &lt;span class="nt"&gt;-delete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add to crontab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;0 3 &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt; /root/backup-n8n.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Performance Optimization Tips
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Enable Redis for scaling&lt;/strong&gt;: Add a Redis container for better performance with multiple workers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use external database&lt;/strong&gt;: For production workloads, consider PostgreSQL instead of SQLite&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor resources&lt;/strong&gt;: Install &lt;code&gt;htop&lt;/code&gt; and set up monitoring with Prometheus/Grafana&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimize workflows&lt;/strong&gt;: Use webhook triggers instead of polling when possible&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Cost Breakdown
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hetzner CX22 Server&lt;/strong&gt;: ~$6/month&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain (use a subdomain)&lt;/strong&gt;: ~$0/year&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total monthly cost&lt;/strong&gt;: ~$6/month&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Compare this to n8n Cloud's pricing starting at $20/month for just 2,500 workflow executions!&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start with security&lt;/strong&gt;: It's easier to harden from the beginning than retrofit later&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker adds complexity but worth it&lt;/strong&gt;: Container isolation and easy updates outweigh the overhead&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caddy &amp;gt; Nginx for simple setups&lt;/strong&gt;: Automatic HTTPS saves hours of configuration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor everything&lt;/strong&gt;: Set up alerts for disk space, CPU usage, and failed login attempts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document everything&lt;/strong&gt;: Future you will thank present you&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Troubleshooting Common Issues
&lt;/h2&gt;

&lt;h3&gt;
  
  
  n8n won't start
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check logs&lt;/span&gt;
docker logs n8n
&lt;span class="c"&gt;# Common fix: permissions on data volume&lt;/span&gt;
docker volume &lt;span class="nb"&gt;rm &lt;/span&gt;n8n_data  &lt;span class="c"&gt;# Warning: deletes data!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  SSL certificate issues
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Caddy logs&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; caddy &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;span class="c"&gt;# Force certificate renewal&lt;/span&gt;
caddy reload &lt;span class="nt"&gt;--config&lt;/span&gt; /etc/caddy/Caddyfile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  High memory usage
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Add memory limits to docker-compose.yml&lt;/span&gt;
deploy:
  resources:
    limits:
      memory: 3g
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;For less than the price of a Netflix subscription, you can run your own powerful automation platform with complete control over your data. The setup takes about an hour, but the flexibility and cost savings make it worthwhile for anyone serious about automation.&lt;/p&gt;

&lt;p&gt;Have you set up your own n8n instance? What challenges did you face? Let me know in the comments!&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Resources:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.n8n.io/" rel="noopener noreferrer"&gt;n8n Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.hetzner.com/cloud" rel="noopener noreferrer"&gt;Hetzner Cloud&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://caddyserver.com/docs/" rel="noopener noreferrer"&gt;Caddy Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.docker.com/develop/security-best-practices/" rel="noopener noreferrer"&gt;Docker Security Best Practices&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>devops</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Monitor Any Stock Price in Your App with 7 Lines of JavaScript</title>
      <dc:creator>Alexander Schneider</dc:creator>
      <pubDate>Sun, 13 Jul 2025 07:35:55 +0000</pubDate>
      <link>https://dev.to/alexanderschneider/monitor-any-stock-price-in-your-app-with-7-lines-of-javascript-5fb4</link>
      <guid>https://dev.to/alexanderschneider/monitor-any-stock-price-in-your-app-with-7-lines-of-javascript-5fb4</guid>
      <description>&lt;p&gt;Ever wanted to add stock price monitoring to your app? Maybe you're building a dashboard, a portfolio tracker, or just want to get notified when Tesla does something crazy again?&lt;/p&gt;

&lt;p&gt;Here's how to add real-time stock monitoring to ANY JavaScript application using the StockAlert.pro API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @stockalert/sdk
&lt;span class="c"&gt;# or&lt;/span&gt;
yarn add @stockalert/sdk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The 7-Line Version (I Wasn't Kidding)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StockAlertClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stockalert/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StockAlertClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;your-free-api-key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Get notified when Apple hits $150&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AAPL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;price_above&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. You'll get an email when Apple hits $150. But let's build something more useful...&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Example: Portfolio Dashboard
&lt;/h2&gt;

&lt;p&gt;Let's build a portfolio monitoring system that tracks multiple stocks and sends you notifications when it's time to take profits or cut losses.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StockAlertClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stockalert/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StockAlertClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STOCKALERT_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Your portfolio&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;portfolio&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;span class="na"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AAPL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;shares&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buyPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;145&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GOOGL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;shares&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buyPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2800&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MSFT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;shares&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buyPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;380&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// Set up price alerts for your portfolio&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;monitorPortfolio&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;position&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;portfolio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Alert when profit hits 10%&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;price_change_up&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;  &lt;span class="c1"&gt;// 10% gain&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// Alert when loss hits 5% (stop loss)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;price_change_down&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;  &lt;span class="c1"&gt;// 5% loss&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`📊 Monitoring &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`   Alert when up 10% from current price`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`   Alert when down 5% from current price`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s1"&gt;✅ All alerts created! You&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;ll get emails when targets are hit.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Start monitoring&lt;/span&gt;
&lt;span class="nf"&gt;monitorPortfolio&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Working with Webhooks
&lt;/h2&gt;

&lt;p&gt;Want to receive events in your app? Here's how to properly set up webhooks:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pro tip&lt;/strong&gt;: You can view and test all your webhooks at &lt;a href="https://stockalert.pro/dashboard/settings" rel="noopener noreferrer"&gt;https://stockalert.pro/dashboard/settings&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Step 1: Register your webhook endpoint&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;webhook&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://your-app.com/stockalert-webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alert.triggered&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Webhook secret:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;webhook&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// Save this for verification!&lt;/span&gt;

&lt;span class="c1"&gt;// Step 2: Create alerts (they'll send emails/SMS by default)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AAPL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;price_above&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Step 3: Handle webhook events when alerts trigger&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/stockalert-webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Verify webhook signature (recommended)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-stockalert-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expectedSignature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;webhook&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;expectedSignature&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid signature&lt;/span&gt;&lt;span class="dl"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alert.triggered&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`🚨 Alert triggered at &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Symbol: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Condition: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (threshold: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Current value: $&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current_value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Your custom logic here: update database, send Slack message, etc.&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;h2&gt;
  
  
  Building a Price Monitoring Slack Bot
&lt;/h2&gt;

&lt;p&gt;Here's a Slack bot that lets your team monitor stocks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StockAlertClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stockalert/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;WebClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@slack/web-api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stockAlert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StockAlertClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STOCKALERT_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WebClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SLACK_TOKEN&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Slack slash command: /watch AAPL above 150&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/slack/commands&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;channel_id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Create the alert&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stockAlert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`price_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Store metadata in your database for webhook handling&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveAlertMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;channel_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user_id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;response_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;in_channel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`✅ Watching &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt; to go &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; $&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Handle the alert&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/slack-webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;slack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;channel_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`🚨 Alert: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is now at $&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;!`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;section&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mrkdwn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`*&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;* hit your target price!\nCurrent: *$&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;*\nAlert by: &amp;lt;@&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;gt;`&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;h2&gt;
  
  
  Email Digest for Your Users
&lt;/h2&gt;

&lt;p&gt;Building a fintech app? Here's how to use daily reminders to keep users informed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StockAlertClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stockalert/sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StockAlertClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STOCKALERT_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Set up daily reminders for user's watchlist&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setupDailyDigest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;watchlist&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;symbol&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;watchlist&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;daily_reminder&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
      &lt;span class="c1"&gt;// Sends every market day at open with current price&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="c1"&gt;// Example: Set up for a user watching tech stocks&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;setupDailyDigest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user@example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AAPL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GOOGL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MSFT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What the user receives every market day:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Subject: Daily Reminder for AAPL: +1.24%

Stock Alert: Daily Reminder for AAPL

Daily Reminder: Apple Inc.
Your daily market open summary for Apple Inc. (AAPL). Current price is $175.23.

Alert Details
Here is your daily update for Apple Inc. (AAPL), as of U.S. market open:

┌─────────────────────────┬──────────────┐
│ Current Price           │ $175.23      │
│ Previous Day's Close    │ $173.08      │
│ Change                  │ +1.24%       │
│ 52-Week High           │ $199.62      │
│ 52-Week Low            │ $164.08      │
│ Volume                  │ 52,341,200   │
└─────────────────────────┴──────────────┘

---
Sent by StockAlert.pro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each stock gets its own email with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Current price at market open&lt;/li&gt;
&lt;li&gt;Previous day's closing price&lt;/li&gt;
&lt;li&gt;Percentage change (color-coded: green/red)&lt;/li&gt;
&lt;li&gt;52-week high and low&lt;/li&gt;
&lt;li&gt;Trading volume&lt;/li&gt;
&lt;li&gt;Proper currency formatting based on user location&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pro tip&lt;/strong&gt;: For a consolidated digest, use webhooks to collect all alerts and send one combined email:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Collect daily alerts via webhook&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dailyAlerts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alert.triggered&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;condition&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;daily_reminder&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Group by user (you'd need to track this in your DB)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getUserIdByAlertId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alert_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;dailyAlerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;dailyAlerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&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="nx"&gt;dailyAlerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current_value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;triggered_at&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// Send digest after collecting all morning alerts&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;sendConsolidatedDigest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;h2&gt;
  
  
  Advanced: Technical Indicators
&lt;/h2&gt;

&lt;p&gt;Monitor RSI, Moving Averages, and more:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Get notified when a stock becomes oversold&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TSLA&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rsi_limit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// RSI below 30 = oversold&lt;/span&gt;
  &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;below&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;  &lt;span class="c1"&gt;// 'below' or 'above'&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Golden Cross alert (50-day MA crosses above 200-day MA)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SPY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ma_crossover_golden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Price touches moving average&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AAPL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ma_touch_above&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;period&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;  &lt;span class="c1"&gt;// 50-day or 200-day moving average&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;h2&gt;
  
  
  Error Handling &amp;amp; Best Practices
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StockMonitor&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StockAlertClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;createAlert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`✅ Alert created: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;RATE_LIMIT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Rate limit hit, retrying in 60s...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createAlert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INVALID_SYMBOL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Invalid symbol: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&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;async&lt;/span&gt; &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Delete all alerts on shutdown&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Failed to delete alert &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Graceful shutdown&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SIGTERM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;monitor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&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;h2&gt;
  
  
  Testing Your Integration
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Mock webhook endpoint for testing&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;development&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/test-webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Webhook received:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Create a test alert&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AAPL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;price_above&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="c1"&gt;// Will trigger immediately&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// For webhook testing, first create a webhook endpoint:&lt;/span&gt;
  &lt;span class="c1"&gt;// const webhook = await client.webhooks.create({&lt;/span&gt;
  &lt;span class="c1"&gt;//   url: 'http://localhost:3000/test-webhook',&lt;/span&gt;
  &lt;span class="c1"&gt;//   events: ['alert.triggered']&lt;/span&gt;
  &lt;span class="c1"&gt;// });&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pricing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Free Plan&lt;/strong&gt; includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Monitor up to 50 stocks parallel&lt;/li&gt;
&lt;li&gt;Unlimited email alerts&lt;/li&gt;
&lt;li&gt;All 21 alert types&lt;/li&gt;
&lt;li&gt;1 API key per user&lt;/li&gt;
&lt;li&gt;100 API requests/hour&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Premium Plan&lt;/strong&gt; ($14.99/month or $149.99/year):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Unlimited stocks&lt;/li&gt;
&lt;li&gt;50 SMS/WhatsApp messages per month&lt;/li&gt;
&lt;li&gt;10,000 API requests/hour&lt;/li&gt;
&lt;li&gt;Unlimited API keys&lt;/li&gt;
&lt;li&gt;Unlimited alerts via API&lt;/li&gt;
&lt;li&gt;Priority support&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Perfect for testing with the free plan, then scale up when you need more power!&lt;/p&gt;

&lt;h2&gt;
  
  
  What Will You Build?
&lt;/h2&gt;

&lt;p&gt;I've seen developers use this API to build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Portfolio tracking apps&lt;/li&gt;
&lt;li&gt;Discord/Slack bots&lt;/li&gt;
&lt;li&gt;Personal dashboards
&lt;/li&gt;
&lt;li&gt;Trading journals&lt;/li&gt;
&lt;li&gt;News aggregators that react to price movements&lt;/li&gt;
&lt;li&gt;Chrome extensions for price monitoring&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What are you planning to build? Drop a comment below!&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;📚 &lt;a href="https://stockalert.pro/api/docs" rel="noopener noreferrer"&gt;Full API Documentation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🔧 &lt;a href="https://www.npmjs.com/package/@stockalert/sdk" rel="noopener noreferrer"&gt;npm Package&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🐍 &lt;a href="https://pypi.org/project/stockalert/" rel="noopener noreferrer"&gt;Python SDK&lt;/a&gt; &lt;/li&gt;
&lt;li&gt;🤖 &lt;a href="https://www.npmjs.com/package/n8n-nodes-stockalert" rel="noopener noreferrer"&gt;n8n Integration&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💬 &lt;a href="https://github.com/orgs/stockalert-pro/discussions" rel="noopener noreferrer"&gt;GitHub Discussions&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Get your free API key at &lt;a href="https://stockalert.pro" rel="noopener noreferrer"&gt;stockalert.pro&lt;/a&gt; - No credit card required&lt;/em&gt;&lt;/p&gt;

</description>
      <category>stocks</category>
      <category>programming</category>
      <category>realtime</category>
      <category>node</category>
    </item>
  </channel>
</rss>
