<?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: Kamil Kujawiński</title>
    <description>The latest articles on DEV Community by Kamil Kujawiński (@kkuj).</description>
    <link>https://dev.to/kkuj</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%2F2458391%2Fdea0ef15-f5f2-41b3-9e81-8c2a87ee8685.jpeg</url>
      <title>DEV Community: Kamil Kujawiński</title>
      <link>https://dev.to/kkuj</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kkuj"/>
    <language>en</language>
    <item>
      <title>n8n caching - One node, no external storage dependencies, easy to use.</title>
      <dc:creator>Kamil Kujawiński</dc:creator>
      <pubDate>Sun, 21 Jun 2026 17:54:01 +0000</pubDate>
      <link>https://dev.to/kkuj/n8n-caching-one-node-no-external-storage-dependencies-easy-to-use-2ig1</link>
      <guid>https://dev.to/kkuj/n8n-caching-one-node-no-external-storage-dependencies-easy-to-use-2ig1</guid>
      <description>&lt;p&gt;Plenty of n8n workflows repeat the same expensive step over and over: calling a rate-limited API, scraping a page, or — increasingly — paying an LLM to summarise the same input twice. The fix is the oldest trick in computing: cache the result the first time, and on every later run hand back the stored copy instead of redoing the work.&lt;/p&gt;

&lt;p&gt;n8n recently shipped &lt;a href="https://docs.n8n.io/data/data-tables/" rel="noopener noreferrer"&gt;data tables&lt;/a&gt;, a built-in per-instance store of typed rows and columns. It's a durable storage that can be reused for cache needs, but wiring up "look it up, branch on hit/miss, write it back, and expire stale entries" by hand is fiddly and easy to get subtly wrong.&lt;/p&gt;

&lt;p&gt;So I packaged it as a community node: &lt;strong&gt;&lt;a href="https://www.npmjs.com/package/n8n-nodes-datatable-cache" rel="noopener noreferrer"&gt;&lt;code&gt;n8n-nodes-datatable-cache&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt;. It's a read-through / write-back cache backed by a data table, with hit/miss routing and TTL expiry built in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;The node has &lt;strong&gt;two inputs&lt;/strong&gt; (&lt;code&gt;Input&lt;/code&gt;, &lt;code&gt;Update&lt;/code&gt;) and &lt;strong&gt;two outputs&lt;/strong&gt; (&lt;code&gt;Cache Hit&lt;/code&gt;, &lt;code&gt;Cache Miss&lt;/code&gt;), designed to be wired as a loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Input  ─▶ ┌─ Data Table Cache ─┐ ─▶ Cache Hit  → use payload
           └────────────────────┘ ─▶ Cache Miss → work ─┐
                ▲──────────────── Update ───────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Input&lt;/strong&gt; looks up an item by key. A fresh hit emits the cached payload on &lt;strong&gt;Cache Hit&lt;/strong&gt;; a miss (or an expired hit) emits the item on &lt;strong&gt;Cache Miss&lt;/strong&gt;, with the stale row attached under &lt;code&gt;_staleRow&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache Miss → your expensive work → the Update input.&lt;/strong&gt; The &lt;strong&gt;Update&lt;/strong&gt; input upserts each processed item and re-emits it on &lt;strong&gt;Cache Hit&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Take &lt;strong&gt;Cache Hit&lt;/strong&gt; onward as your "I have the data" path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The net effect: the expensive branch only runs on a miss, and everything downstream of &lt;strong&gt;Cache Hit&lt;/strong&gt; sees a consistent payload whether it came from cache or was just computed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Requirements:&lt;/strong&gt; an n8n version whose public API serves &lt;code&gt;/api/v1/data-tables&lt;/code&gt; (older instances return 404), and &lt;code&gt;executionOrder: v1&lt;/code&gt; — the default on recent n8n.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Setup (once per instance)
&lt;/h2&gt;

&lt;p&gt;These four steps are a one-time thing per n8n instance. After that, every new workflow just reuses the table and credential.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Install the community node
&lt;/h3&gt;

&lt;p&gt;Go to &lt;strong&gt;Settings → Community Nodes → Install&lt;/strong&gt; and enter &lt;code&gt;n8n-nodes-datatable-cache&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpt3rtu94389ecytgxo8j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpt3rtu94389ecytgxo8j.png" alt="Installing the community node from Settings → Community Nodes" width="800" height="429"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You now have a &lt;strong&gt;Data Table Cache&lt;/strong&gt; node in the node panel.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Create the cache data table
&lt;/h3&gt;

&lt;p&gt;Each cached item is one row. The schema is four columns (n8n adds &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;createdAt&lt;/code&gt;, &lt;code&gt;updatedAt&lt;/code&gt; automatically):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Required?&lt;/th&gt;
&lt;th&gt;Holds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cache_key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;The lookup key (a record id, a hash…)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;payload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;JSON.stringify&lt;/code&gt; of the cached item&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;last_modified&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Datetime (or String)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Timestamp of the last write — the default TTL source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;last_access&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Datetime (or String)&lt;/td&gt;
&lt;td&gt;Optional&lt;/td&gt;
&lt;td&gt;Timestamp of the last hit — only for idle TTL / LRU&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fastest way to get all four columns right is to import a CSV with the correct header row. Download &lt;strong&gt;&lt;a href="https://digit11.com/blog/n8n-data-table-cache/cache-table.csv" rel="noopener noreferrer"&gt;&lt;code&gt;cache-table.csv&lt;/code&gt;&lt;/a&gt;&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;cache_key,payload,last_modified,last_access
example-key,"{""value"":""hello"",""count"":42}",2026-06-20T12:00:00.000Z,2026-06-20T12:00:00.000Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;strong&gt;Data tables → Create&lt;/strong&gt;, name the table, choose &lt;strong&gt;Import CSV&lt;/strong&gt;, pick the file, and keep &lt;strong&gt;"My CSV file contains a header row"&lt;/strong&gt; ticked:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi1clmnmigwsf29hv2d42.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi1clmnmigwsf29hv2d42.png" alt="Creating a data table from the CSV import" width="800" height="776"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the next screen, confirm the column types — &lt;strong&gt;&lt;code&gt;cache_key&lt;/code&gt; and &lt;code&gt;payload&lt;/code&gt; are String&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;last_modified&lt;/code&gt; and &lt;code&gt;last_access&lt;/code&gt; are Datetime&lt;/strong&gt; — then &lt;strong&gt;Create&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F56bk80qs9klyniofd0ti.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F56bk80qs9klyniofd0ti.png" alt="Setting the data table column types" width="800" height="687"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A couple of things worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;payload&lt;/code&gt; must be String.&lt;/strong&gt; A &lt;code&gt;json&lt;/code&gt;-typed column breaks the &lt;code&gt;JSON.stringify&lt;/code&gt; / &lt;code&gt;JSON.parse&lt;/code&gt; round-trip — hits come back as &lt;code&gt;{ "_raw": ... }&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timestamps can be Datetime or String.&lt;/strong&gt; The node writes ISO-8601 UTC and reads it back as UTC either way, so TTL stays correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;last_access&lt;/code&gt; is optional.&lt;/strong&gt; A minimal table is just &lt;code&gt;cache_key&lt;/code&gt; + &lt;code&gt;payload&lt;/code&gt; + &lt;code&gt;last_modified&lt;/code&gt;; add &lt;code&gt;last_access&lt;/code&gt; only if you want idle-time (last-access) expiry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Delete the seeded &lt;code&gt;example-key&lt;/code&gt; row once the table exists, and &lt;strong&gt;copy the table's ID from the URL&lt;/strong&gt; — you'll paste it into the node.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Create an n8n API key
&lt;/h3&gt;

&lt;p&gt;The node reads and writes the data table through n8n's own public API, so it needs an API key with data-table scopes. Go to &lt;strong&gt;Settings → n8n API → Create an API key&lt;/strong&gt; and grant: &lt;code&gt;dataTable:list&lt;/code&gt;, &lt;code&gt;dataTableRow:read&lt;/code&gt;, &lt;code&gt;dataTableRow:upsert&lt;/code&gt;, &lt;code&gt;dataTableRow:update&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs40yhj32z3lwoswaroav.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs40yhj32z3lwoswaroav.png" alt="Creating an n8n API key with data-table scopes" width="799" height="485"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Create the n8n API credential
&lt;/h3&gt;

&lt;p&gt;Under &lt;strong&gt;Credentials → New → n8n API&lt;/strong&gt;, fill in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API Key&lt;/strong&gt; — the key from step 3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Base URL&lt;/strong&gt; — your instance URL &lt;strong&gt;ending in &lt;code&gt;/api/v1&lt;/code&gt;&lt;/strong&gt;, e.g. &lt;code&gt;https://your-n8n-host/api/v1&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Save it; you should see &lt;strong&gt;"Connection tested successfully"&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fultquyu1d683h606280z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fultquyu1d683h606280z.png" alt="Configuring the n8n API credential, with Base URL ending in /api/v1" width="800" height="666"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A &lt;code&gt;404&lt;/code&gt; when the node opens its &lt;strong&gt;Data Table&lt;/strong&gt; dropdown almost always means the Base URL is missing &lt;code&gt;/api/v1&lt;/code&gt;, or your n8n version predates the public data-table API.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Using it in a workflow
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Import the example
&lt;/h3&gt;

&lt;p&gt;The quickest way to see the loop is to import a working example. Download &lt;strong&gt;&lt;a href="https://digit11.com/blog/n8n-data-table-cache/example-workflow.json" rel="noopener noreferrer"&gt;&lt;code&gt;example-workflow.json&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; and bring it in via &lt;strong&gt;Workflows → Import from File&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuh0pml4sccc0lwc8pac3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuh0pml4sccc0lwc8pac3.png" alt="The example workflow on the n8n canvas" width="800" height="498"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's the full read-through / write-back loop in four nodes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Webhook ─▶ [Input] Data Table Cache [Cache Hit] ─▶ Respond to Webhook
                                    [Cache Miss] ─▶ Heavy processing
                                                          │
                        [Update] ◀────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A request comes in on the &lt;strong&gt;Webhook&lt;/strong&gt;, gets looked up, and a fresh hit responds immediately. A miss flows through the &lt;strong&gt;Heavy processing&lt;/strong&gt; node (stand in your real API call, scrape, or LLM step here) and back into &lt;strong&gt;Update&lt;/strong&gt;, which stores the result and re-emits it on &lt;strong&gt;Cache Hit&lt;/strong&gt; so the response path is identical either way.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If the import errors with &lt;em&gt;"unknown node type"&lt;/em&gt;, you skipped step 1 — install the community node and reload.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Configure the node
&lt;/h3&gt;

&lt;p&gt;Open the &lt;strong&gt;Data Table Cache&lt;/strong&gt; node and set the credential, the table, and the key:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8l3g2k3fxkdups83squb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8l3g2k3fxkdups83squb.png" alt="The Data Table Cache node parameters" width="800" height="1033"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Data Table&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Pick from the list or paste the table ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key Column&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cache_key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Column matched against the cache key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache Key&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Value to look up (Input) or store under (Update)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payload Column&lt;/td&gt;
&lt;td&gt;&lt;code&gt;payload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Holds the JSON-stringified payload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Last Modified Column&lt;/td&gt;
&lt;td&gt;&lt;code&gt;last_modified&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ISO timestamp of the last write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Last Access Column&lt;/td&gt;
&lt;td&gt;&lt;code&gt;last_access&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ISO timestamp of the last hit (leave empty to skip)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max Age + Unit&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;3600&lt;/code&gt; s&lt;/td&gt;
&lt;td&gt;A hit older than this becomes a miss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Measure From&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Last Modified&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whether TTL counts from &lt;code&gt;last_modified&lt;/code&gt; or &lt;code&gt;last_access&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The column names default to match the table from setup, so you normally leave them alone. The one field you always set is &lt;strong&gt;Cache Key&lt;/strong&gt; — derive it from something present on both the lookup item and the processed item, e.g. &lt;code&gt;={{ $json.query.key }}&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;One item = one JSON object, not an array.&lt;/strong&gt; n8n passes items individually, so the &lt;strong&gt;Update&lt;/strong&gt; input stores a single item's &lt;code&gt;$json&lt;/code&gt; per key, and &lt;strong&gt;Cache Hit&lt;/strong&gt; emits that same object. To cache a collection under one key, wrap it first (e.g. &lt;code&gt;{ "items": [...] }&lt;/code&gt;) so it travels as a single item.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  TTL and expiry
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Max Age + Unit&lt;/strong&gt; is how long a hit stays fresh; older hits route to &lt;strong&gt;Cache Miss&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure From &lt;code&gt;Last Modified&lt;/code&gt;&lt;/strong&gt; is time since the value was cached — what most caches want, and it needs no &lt;code&gt;last_access&lt;/code&gt; column.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure From &lt;code&gt;Last Access&lt;/code&gt;&lt;/strong&gt; is time since it was last read — combine it with a scheduled cleanup for LRU-style eviction. This mode requires the &lt;strong&gt;Last Access Column&lt;/strong&gt; to be set.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Keep the table pruned
&lt;/h3&gt;

&lt;p&gt;Data tables don't auto-delete expired rows, so the table grows until you prune it. Add a separate scheduled workflow — a &lt;strong&gt;Schedule Trigger&lt;/strong&gt; into a built-in &lt;strong&gt;Data Table&lt;/strong&gt; node (operation &lt;strong&gt;Delete Rows&lt;/strong&gt;) pointed at the same table, filtering &lt;code&gt;last_modified&lt;/code&gt; &lt;strong&gt;less than&lt;/strong&gt; a cutoff like &lt;code&gt;={{ $now.minus({ days: 7 }).toUTC().toISO() }}&lt;/code&gt;. Because the timestamps are ISO-8601, a less-than comparison sorts them chronologically. (This cleanup uses the plain Data Table node, so it needs no API key.)&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;All data tables in an instance share a default &lt;strong&gt;50 MB&lt;/strong&gt; limit, so keep payloads compact (&lt;code&gt;N8N_DATA_TABLES_MAX_SIZE_BYTES&lt;/code&gt; raises it on self-hosted).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Good to know
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Expiry is TTL-only&lt;/strong&gt; for now (filter-condition mode is planned).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency is last-write-wins&lt;/strong&gt; — perfectly fine for a cache, not for transactional data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Malformed payloads degrade&lt;/strong&gt; to &lt;code&gt;{ _raw: &amp;lt;value&amp;gt; }&lt;/code&gt; rather than throwing.&lt;/li&gt;
&lt;li&gt;With &lt;strong&gt;Continue On Fail&lt;/strong&gt; on, errored items are emitted with an &lt;code&gt;error&lt;/code&gt; field instead of failing the run (lookup errors go to Cache Miss, store errors to Cache Hit).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The node is MIT-licensed and on npm as &lt;strong&gt;&lt;a href="https://www.npmjs.com/package/n8n-nodes-datatable-cache" rel="noopener noreferrer"&gt;&lt;code&gt;n8n-nodes-datatable-cache&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt;. Drop it into the next workflow that's redoing expensive work on every run.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://digit11.com/blog/n8n-data-table-cache/" rel="noopener noreferrer"&gt;digit11.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>npmjs</category>
      <category>automation</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Turn a Google Sheet Into an iPhone and Apple Watch Widget</title>
      <dc:creator>Kamil Kujawiński</dc:creator>
      <pubDate>Sun, 07 Jun 2026 20:25:17 +0000</pubDate>
      <link>https://dev.to/kkuj/turn-a-google-sheet-into-an-iphone-and-apple-watch-widget-1m90</link>
      <guid>https://dev.to/kkuj/turn-a-google-sheet-into-an-iphone-and-apple-watch-widget-1m90</guid>
      <description>&lt;p&gt;A spreadsheet is where most of us already track the small numbers of life: the home budget, the side-project revenue, the litres of fuel, the kilometres run. The annoying part is that the number only exists when you open the sheet. You can't glance at it.&lt;/p&gt;

&lt;p&gt;This post closes that gap. With two pieces you get any Google Sheet on your iPhone home screen and Apple Watch face:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A small &lt;strong&gt;n8n workflow&lt;/strong&gt; that reads your sheet, keeps only the columns you care about, computes the aggregations you ask for (total, average, per-owner breakdown…), and exposes it as a JSON webhook.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;API Widgets&lt;/strong&gt; iOS app, which reads that webhook and renders it as a widget.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We'll build a &lt;strong&gt;shared household budget&lt;/strong&gt; widget as the worked example - a donut of who's saving how much, next to a table of where the money went - but nothing in the workflow is budget-specific. Swap the sheet and the config and you have a sales dashboard, a fitness tracker, or a fuel log.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm3gnx3mgjw7dc0kp729d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm3gnx3mgjw7dc0kp729d.png" alt="The Google Sheets webhook workflow in n8n" width="800" height="335"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://digit11.com/blog/google-sheets-widget/n8n-workflow-google-sheets.json" rel="noopener noreferrer"&gt;Download the workflow&lt;/a&gt; and import it into your n8n instance.&lt;/p&gt;

&lt;p&gt;It's only five nodes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Webhook&lt;/strong&gt; - exposes the endpoint &lt;code&gt;GET /webhook/sheets&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configuration&lt;/strong&gt; - a Code node that returns &lt;em&gt;which&lt;/em&gt; sheet to read and &lt;em&gt;what&lt;/em&gt; to compute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get row(s) in sheet&lt;/strong&gt; - pulls every row from the Google Sheet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spreadsheet to JSON&lt;/strong&gt; - keeps the configured columns and runs the aggregations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Respond to Webhook&lt;/strong&gt; - returns the result as JSON.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is &lt;strong&gt;no caching and no database&lt;/strong&gt;. Every call to the webhook reads the sheet live and returns the current numbers. That keeps the workflow trivial to set up - nothing to provision, no stale data to reason about. If you ever put this in front of heavy traffic, that's the point where you'd add a cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: the Google Sheet
&lt;/h2&gt;

&lt;p&gt;Make a sheet with a header row and one row per record. For the shared-budget example, the columns are &lt;code&gt;category&lt;/code&gt;, &lt;code&gt;budget&lt;/code&gt;, &lt;code&gt;spent&lt;/code&gt;, &lt;code&gt;left&lt;/code&gt;, and &lt;code&gt;owner&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpyruewkf4ohzylg2sq52.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpyruewkf4ohzylg2sq52.png" alt="The shared budget Google Sheet" width="800" height="582"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In n8n, create a &lt;strong&gt;Google Sheets&lt;/strong&gt; credential (OAuth2) and give it access to this sheet. The &lt;code&gt;Get row(s) in sheet&lt;/code&gt; node uses that credential; the sheet itself is identified by its &lt;strong&gt;document ID&lt;/strong&gt; - the long string in the sheet URL between &lt;code&gt;/d/&lt;/code&gt; and &lt;code&gt;/edit&lt;/code&gt;. That ID isn't typed into the node directly; it comes from the Configuration node (next step), so pointing the workflow at a different sheet is a one-line change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: where you configure it
&lt;/h2&gt;

&lt;p&gt;This is the part that replaces the database in fancier setups: the &lt;strong&gt;Configuration&lt;/strong&gt; node. It's a plain Code node that returns three things - the sheet to read, the columns to keep, and the aggregations to compute. Open it and edit the values:&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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;YOUR_GOOGLE_SHEET_ID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;columns&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;position&lt;/span&gt;&lt;span class="dl"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;header&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="s2"&gt;category&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="s2"&gt;type&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="s2"&gt;text&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="s2"&gt;position&lt;/span&gt;&lt;span class="dl"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;header&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="s2"&gt;budget&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="s2"&gt;type&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="s2"&gt;number&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="s2"&gt;position&lt;/span&gt;&lt;span class="dl"&gt;"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;header&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="s2"&gt;spent&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="s2"&gt;type&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="s2"&gt;number&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="s2"&gt;position&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;header&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="s2"&gt;left&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="s2"&gt;type&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="s2"&gt;number&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="s2"&gt;position&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;header&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="s2"&gt;owner&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="s2"&gt;type&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="s2"&gt;text&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;aggregations&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&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="s2"&gt;savings&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="s2"&gt;group_by&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="s2"&gt;owner&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="s2"&gt;field&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="s2"&gt;left&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="s2"&gt;agg&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="s2"&gt;sum&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="s2"&gt;name&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="s2"&gt;responsibilities&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="s2"&gt;group_by&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="s2"&gt;owner&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="s2"&gt;field&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="s2"&gt;owner&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="s2"&gt;agg&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="s2"&gt;count&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;source&lt;/code&gt;&lt;/strong&gt; - paste your Google Sheet document ID here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;columns&lt;/code&gt;&lt;/strong&gt; - the headers to keep, in order. &lt;code&gt;header&lt;/code&gt; must match the column name in the sheet row for row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;aggregations&lt;/code&gt;&lt;/strong&gt; - the summaries to compute. Each one supports &lt;code&gt;sum&lt;/code&gt;, &lt;code&gt;avg&lt;/code&gt;, &lt;code&gt;min&lt;/code&gt;, &lt;code&gt;max&lt;/code&gt;, and &lt;code&gt;count&lt;/code&gt;. Add &lt;code&gt;group_by&lt;/code&gt; to get a value &lt;em&gt;per&lt;/em&gt; distinct group instead of one overall number, and &lt;code&gt;name&lt;/code&gt; is the key it shows up under in the response.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The example asks for two numbers per person: &lt;code&gt;savings&lt;/code&gt; (the sum of each owner's &lt;code&gt;left&lt;/code&gt; column) and &lt;code&gt;responsibilities&lt;/code&gt; (how many budget categories each owner is on the hook for).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the transform
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Spreadsheet to JSON&lt;/code&gt; Code node reads the configuration, picks the requested columns out of every row, and runs the aggregations:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;columns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&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;Configuration&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&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="nx"&gt;columns&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;aggregations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&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;Configuration&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&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="nx"&gt;aggregations&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;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&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;item&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="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&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;col&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;header&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;out&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;aggregators&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;Number&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;0&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="na"&gt;avg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vals&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;vals&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;vals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nc"&gt;Number&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;0&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;/&lt;/span&gt; &lt;span class="nx"&gt;vals&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vals&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;vals&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;vals&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="nb"&gt;Number&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="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vals&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;vals&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;vals&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="nb"&gt;Number&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="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;vals&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;aggResult&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;agg&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;aggregations&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;fn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aggregators&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;agg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agg&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;outKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;agg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;agg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;field&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;agg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;group_by&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;grouped&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;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;items&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;key&lt;/span&gt; &lt;span class="o"&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;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;agg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;group_by&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grouped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||=&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;agg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;field&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;groupOut&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grouped&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;groupOut&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vals&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;aggResult&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;outKey&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;groupOut&lt;/span&gt;&lt;span class="p"&gt;;&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="nx"&gt;aggResult&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;outKey&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&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;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;agg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;aggregations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;aggResult&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;For the sheet above, the response looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Groceries"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="nl"&gt;"budget"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"600"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"412.5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"left"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"187.5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ann"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Restaurants"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"budget"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"200"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"250"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nl"&gt;"left"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-50"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ann"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Transport"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="nl"&gt;"budget"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"150"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"92.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nl"&gt;"left"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"57.9"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Robert"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Utilities"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="nl"&gt;"budget"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"300"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"287.4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"left"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"12.6"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Robert"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Entertainment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"budget"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"150"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"64"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="nl"&gt;"left"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"86"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nl"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ann"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Shopping"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nl"&gt;"budget"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"200"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"143.8"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"left"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"56.2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ann"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Health"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nl"&gt;"budget"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"100"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"45"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="nl"&gt;"left"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"55"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nl"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ann"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Subscriptions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"budget"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"80"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"spent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"79.99"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"left"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Robert"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Other"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="nl"&gt;"budget"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"300"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"156.7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"left"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"143.3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ann"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aggregations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"savings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Ann"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;478&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Robert"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;70.51&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"responsibilities"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Ann"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Robert"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;items&lt;/code&gt; is the raw table; &lt;code&gt;aggregations&lt;/code&gt; is the computed summary. The widget can show either - the per-owner &lt;code&gt;savings&lt;/code&gt; as a chart, or the full &lt;code&gt;items&lt;/code&gt; table as a list. We'll use both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: wire it up to API Widgets
&lt;/h2&gt;

&lt;p&gt;Install &lt;a href="https://apps.apple.com/app/api-widgets/id6756238482" rel="noopener noreferrer"&gt;API Widgets&lt;/a&gt; on your iPhone.&lt;/p&gt;

&lt;p&gt;In the &lt;strong&gt;Source&lt;/strong&gt; tab, paste the webhook 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://n8n.example.com/webhook/sheets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then switch to the &lt;strong&gt;Design&lt;/strong&gt; tab. For this budget we use the &lt;strong&gt;Split&lt;/strong&gt; widget type - two panes side by side, bound to two parts of the JSON:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fml5d0aqzmkbowegqhlrw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fml5d0aqzmkbowegqhlrw.png" alt="API Widgets Design tab with a Split widget" width="800" height="1734"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Left column → Pie&lt;/strong&gt;, with the Data Source expression &lt;code&gt;${api['aggregations']['savings']}&lt;/code&gt; - the donut of savings per owner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right column → Table&lt;/strong&gt;, with the Source expression &lt;code&gt;${api['items']}&lt;/code&gt; - the list of categories and what was spent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The expression language reaches straight into the JSON the workflow returns, so anything you put under &lt;code&gt;items&lt;/code&gt; or &lt;code&gt;aggregations&lt;/code&gt; is one expression away from being on screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final result
&lt;/h2&gt;

&lt;p&gt;Tap the preview (the eye icon) and you can see the finished widget right in the app before placing it - the savings donut for Ann and Robert next to the spending table:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwqdg9lspa78gmpx6los6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwqdg9lspa78gmpx6los6.png" alt="API Widgets preview of the budget widget" width="591" height="640"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Add it to your home screen as usual, and the same layout works on the Apple Watch face (the watch widget requires the pro version of API Widgets).&lt;/p&gt;

&lt;p&gt;The sheet stays the single source of truth - edit a row, and the next time the widget refreshes, your wrist catches up. Swap the sheet ID and the Configuration node, and the same workflow serves any number you keep in a spreadsheet.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://digit11.com/blog/google-sheets-widget/" rel="noopener noreferrer"&gt;digit11.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>googlesheets</category>
      <category>iphone</category>
      <category>applewatch</category>
    </item>
    <item>
      <title>Search Sofascore from Your Browser's Address Bar</title>
      <dc:creator>Kamil Kujawiński</dc:creator>
      <pubDate>Tue, 21 Apr 2026 21:21:18 +0000</pubDate>
      <link>https://dev.to/kkuj/search-sofascore-from-your-browsers-address-bar-56e0</link>
      <guid>https://dev.to/kkuj/search-sofascore-from-your-browsers-address-bar-56e0</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frrm5ef8mb8yw2ftcutcu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frrm5ef8mb8yw2ftcutcu.png" alt=" " width="800" height="180"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://digit11.com/blog/sofascore-search/sofascore_search_engine.gif" rel="noopener noreferrer"&gt;Sofascore browser search - GIF example&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I watch a lot of football and I'm constantly looking things up on &lt;a href="https://www.sofascore.com" rel="noopener noreferrer"&gt;Sofascore&lt;/a&gt;. Opening the site, clicking the magnifier, typing — it adds up. What I want is: type &lt;code&gt;sofa barcelona&lt;/code&gt; in my address bar, hit Enter, land on the search results.&lt;/p&gt;

&lt;p&gt;Sofascore's URL doesn't accept a &lt;code&gt;?q=&lt;/code&gt; parameter natively, and the search input is a React-controlled field that ignores programmatically-set values. Getting from the address bar to a live Sofascore search takes three pieces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;browser keyword shortcut&lt;/strong&gt; that routes &lt;code&gt;sofa &amp;lt;query&amp;gt;&lt;/code&gt; to Sofascore with the query attached as &lt;code&gt;?q=&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Tampermonkey userscript&lt;/strong&gt; that reads &lt;code&gt;?q=&lt;/code&gt; on page load and feeds it into the search box&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;local typing service&lt;/strong&gt; that actually types the query, key by key, because the React input won't accept anything less&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  1. Add the browser keyword
&lt;/h3&gt;

&lt;p&gt;Chrome (and any Chromium browser) supports address-bar search keywords. Add one pointing at Sofascore:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open &lt;code&gt;chrome://settings/searchEngines&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Add&lt;/strong&gt; under "Site search"&lt;/li&gt;
&lt;li&gt;Set:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Name&lt;/strong&gt;: Sofascore&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shortcut&lt;/strong&gt;: &lt;code&gt;sofa&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;URL&lt;/strong&gt;: &lt;code&gt;https://www.sofascore.com/pl/?q=%s&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;Firefox and Safari have equivalent settings — the important part is the &lt;code&gt;%s&lt;/code&gt; placeholder, which the browser replaces with whatever you typed after the keyword.&lt;/p&gt;

&lt;p&gt;Now typing &lt;code&gt;sofa barcelona&lt;/code&gt; in the address bar navigates to &lt;code&gt;https://www.sofascore.com/pl/?q=barcelona&lt;/code&gt;. Sofascore itself ignores the &lt;code&gt;?q=&lt;/code&gt;; the userscript picks it up from there.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Userscript: turn ?q= into a real search
&lt;/h3&gt;

&lt;p&gt;Install &lt;a href="https://www.tampermonkey.net/" rel="noopener noreferrer"&gt;Tampermonkey&lt;/a&gt;, then add this script:&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;// ==UserScript==&lt;/span&gt;
&lt;span class="c1"&gt;// @name         Websearch for Sofascore&lt;/span&gt;
&lt;span class="c1"&gt;// @namespace    https://digit11.com/&lt;/span&gt;
&lt;span class="c1"&gt;// @version      1.0.0&lt;/span&gt;
&lt;span class="c1"&gt;// @description  Forward ?q= search terms into Sofascore's search input via Real Type&lt;/span&gt;
&lt;span class="c1"&gt;// @author       kkujawinski&lt;/span&gt;
&lt;span class="c1"&gt;// @match        https://www.sofascore.com/*&lt;/span&gt;
&lt;span class="c1"&gt;// @icon         https://www.google.com/s2/favicons?sz=64&amp;amp;domain=sofascore.com&lt;/span&gt;
&lt;span class="c1"&gt;// @grant        none&lt;/span&gt;
&lt;span class="c1"&gt;// ==/UserScript==&lt;/span&gt;

&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&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;use strict&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;TYPING_SERVER_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:8765&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;realType&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;options&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="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&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;focusFirst&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="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// Focus the current element if requested&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;focusFirst&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activeElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Small delay to ensure focus&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&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="c1"&gt;// Send to macOS typing service&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;response&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TYPING_SERVER_URL&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/type&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="na"&gt;headers&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;Content-Type&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;application/json&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;body&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;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delay&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Server responded with &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&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;response&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="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;Typing completed:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&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;result&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Failed to connect to typing service:&lt;/span&gt;&lt;span class="dl"&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="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;Make sure the macOS typing service is running on port 8765&lt;/span&gt;&lt;span class="dl"&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getQueryParam&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;urlParams&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;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&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;urlParams&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;name&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="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;searchValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getQueryParam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;q&lt;/span&gt;&lt;span class="dl"&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;+$/&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchValue&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;simulateFocusClickLoop&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;input&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;maxAttempts&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="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input#search-input&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;maxAttempts&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="nx"&gt;maxAttempts&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="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;break&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="nx"&gt;maxAttempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&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="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.z_dropdown input[type="radio"][name="all"]&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;maxAttempts&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="nx"&gt;maxAttempts&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="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;break&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;focusing...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&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;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;realType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchValue&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="c1"&gt;// input.value = searchValue;&lt;/span&gt;
            &lt;span class="c1"&gt;// input.dispatchEvent(new InputEvent('input', { bubbles: true }));&lt;/span&gt;
        &lt;span class="p"&gt;})();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reads the &lt;code&gt;q&lt;/code&gt; query parameter (with a trailing-slash guard for browsers that append one).&lt;/li&gt;
&lt;li&gt;Polls for &lt;code&gt;input#search-input&lt;/code&gt; to appear, then focuses and clicks it in a loop until the search dropdown (&lt;code&gt;.z_dropdown input[type="radio"][name="all"]&lt;/code&gt;) shows up — Sofascore's signal that the input is ready to accept keystrokes.&lt;/li&gt;
&lt;li&gt;Calls &lt;code&gt;realType(searchValue)&lt;/code&gt;, which forwards the query to a local typing server.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Why not just &lt;code&gt;input.value = searchValue&lt;/code&gt;? That's the commented-out line at the bottom — it's what you reach for first, and it doesn't work. React's controlled-input handlers ignore the direct assignment, and dispatching a synthetic &lt;code&gt;InputEvent&lt;/code&gt; gets filtered out too. The search input stays empty.&lt;/p&gt;

&lt;p&gt;The typing server exists for exactly this reason. That's the last piece.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Real Type — typing like a real keyboard
&lt;/h3&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/kkujawinski/real-type-server" rel="noopener noreferrer"&gt;kkujawinski/real-type-server&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The AppleScript doing the work&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The core is a handful of AppleScript lines driving System Events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight applescript"&gt;&lt;code&gt;&lt;span class="k"&gt;tell&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;application&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"System Events"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="k"&gt;repeat&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;with&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;i&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;length&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;textToType&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;currentChar&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;character&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;i&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;textToType&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;keystroke&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;currentChar&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nb"&gt;delay&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;charDelay&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;repeat&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;tell&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;keystroke&lt;/code&gt; is the important bit. Unlike pasting or setting a field's value, it emits events indistinguishable from a real keyboard. The target app sees key-down / key-up events arriving at human pace, so input validators, focus handlers — all of it just works. Sofascore's React input included.&lt;/p&gt;

&lt;p&gt;The standalone &lt;code&gt;macos-typer.applescript&lt;/code&gt; file wraps this loop so you can invoke it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;osascript macos-typer.applescript &lt;span class="s2"&gt;"hello world"&lt;/span&gt; 50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A tiny Python server in front&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To make the typer callable from a browser userscript — or a shell alias, or another machine on localhost — there's a thin Python HTTP server (&lt;code&gt;osascript-typing-server.py&lt;/code&gt;) on port 8765. It's &lt;code&gt;http.server&lt;/code&gt; from the stdlib, no dependencies.&lt;/p&gt;

&lt;p&gt;It accepts one route, &lt;code&gt;POST /type&lt;/code&gt;, reads &lt;code&gt;{ "text", "delay" }&lt;/code&gt; from the JSON body, escapes the text for AppleScript, shells out to &lt;code&gt;osascript -e …&lt;/code&gt;, and returns a JSON status. You can call it straight from a webpage — exactly what the userscript does.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8765/type &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"text": "Hello, world!", "delay": 50}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole server. The value isn't in the Python — it's in giving the AppleScript a stable local endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Autostart at login&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;install-autostart.sh&lt;/code&gt; writes a LaunchAgent plist to &lt;code&gt;~/Library/LaunchAgents/&lt;/code&gt; and &lt;code&gt;launchctl&lt;/code&gt; loads it. From then on the server boots with your user session, restarts automatically if it crashes, and logs to &lt;code&gt;/tmp/osascript-typing-server.log&lt;/code&gt;. One-time setup: grant Accessibility permission to the Python binary the installer reports, otherwise &lt;code&gt;keystroke&lt;/code&gt; silently no-ops.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;uninstall-autostart.sh&lt;/code&gt; and &lt;code&gt;check-status.sh&lt;/code&gt; handle the rest of the lifecycle.&lt;/p&gt;

&lt;p&gt;Full source, install scripts, and the LaunchAgent plist are in the &lt;a href="https://github.com/kkujawinski/real-type-server" rel="noopener noreferrer"&gt;repo&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Putting it together
&lt;/h3&gt;

&lt;p&gt;With the three pieces in place, the flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Type &lt;code&gt;sofa barcelona&lt;/code&gt; in the address bar, hit Enter.&lt;/li&gt;
&lt;li&gt;Browser navigates to &lt;code&gt;https://www.sofascore.com/pl/?q=barcelona&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Userscript waits for the search input to be ready, then hands the query off to the typing server.&lt;/li&gt;
&lt;li&gt;Real Type types &lt;code&gt;barcelona&lt;/code&gt; into the focused field at human pace.&lt;/li&gt;
&lt;li&gt;Sofascore's search dropdown fills with live results.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The same pattern works for any site whose search resists synthetic input — swap the selector and the keyword URL, and you've got another shortcut.&lt;/p&gt;

</description>
      <category>sofascore</category>
      <category>browser</category>
      <category>osx</category>
    </item>
    <item>
      <title>Monitor Google Cloud Platform Costs on Your iPhone and Apple Watch</title>
      <dc:creator>Kamil Kujawiński</dc:creator>
      <pubDate>Mon, 23 Mar 2026 18:51:37 +0000</pubDate>
      <link>https://dev.to/kkuj/monitor-google-cloud-platform-costs-on-your-iphone-and-apple-watch-420f</link>
      <guid>https://dev.to/kkuj/monitor-google-cloud-platform-costs-on-your-iphone-and-apple-watch-420f</guid>
      <description>&lt;p&gt;Keep an eye on your GCP costs right from your wrist. Don't let autoscaling silently run up your bill — stay informed with a quick glance at your iPhone or Apple Watch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Here's the plan:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Configure GCP billing export to a BigQuery table&lt;/li&gt;
&lt;li&gt;Create an n8n workflow that queries BigQuery for cost data&lt;/li&gt;
&lt;li&gt;Set up a widget in API Widgets to display the results&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Configure GCP Billing Export to BigQuery
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Enable the BigQuery API in GCP&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5276ackltexkp53440mv.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5276ackltexkp53440mv.jpeg" alt="Enable BigQuery API in the GCP console" width="800" height="364"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Enable billing export to BigQuery&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy5qintr2p0qubysq8ivd.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy5qintr2p0qubysq8ivd.jpeg" alt="Enable billing export to BigQuery" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Create a service account for BigQuery access&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud iam service-accounts create n8n-bigquery-reader &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--display-name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"n8n BigQuery Reader"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOUR_PROJECT_ID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Assign minimal permissions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The service account needs just two roles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;bigquery.jobUser&lt;/code&gt; — allows running queries&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bigquery.dataViewer&lt;/code&gt; — allows reading tables and views (read-only)
&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;# Allow running queries and reading results&lt;/span&gt;
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--member&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"serviceAccount:n8n-bigquery-reader@YOUR_PROJECT_ID.iam.gserviceaccount.com"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"roles/bigquery.jobUser"&lt;/span&gt;

&lt;span class="c"&gt;# Allow reading data from datasets and tables&lt;/span&gt;
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--member&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"serviceAccount:n8n-bigquery-reader@YOUR_PROJECT_ID.iam.gserviceaccount.com"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"roles/bigquery.dataViewer"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;5. Generate a service account key for n8n&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud iam service-accounts keys create n8n-bigquery-key.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--iam-account&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;n8n-bigquery-reader@YOUR_PROJECT_ID.iam.gserviceaccount.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This generates a &lt;code&gt;n8n-bigquery-key.json&lt;/code&gt; file containing the service account credentials. You'll use this file to authenticate the BigQuery node in n8n.&lt;/p&gt;

&lt;h3&gt;
  
  
  Set Up the n8n Workflow
&lt;/h3&gt;

&lt;p&gt;The workflow exposes a webhook endpoint that API Widgets can call. When triggered, it queries BigQuery for your billing data, groups costs by date and service, and returns the results as JSON.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5bnrycuaij6xqqy1rljj.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5bnrycuaij6xqqy1rljj.jpeg" alt="n8n workflow for querying GCP billing costs" width="800" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The workflow consists of five nodes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Webhook&lt;/strong&gt; — listens for incoming requests (accepts an optional &lt;code&gt;days&lt;/code&gt; query parameter, defaults to 14)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configure variables&lt;/strong&gt; — sets the BigQuery table name and the number of days to query&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Execute BigQuery&lt;/strong&gt; — runs a SQL query that aggregates daily costs by service&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Format data&lt;/strong&gt; — transforms the results into a structured JSON response with daily totals and per-service breakdowns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Respond to Webhook&lt;/strong&gt; — sends the formatted data back to the caller&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can import the workflow into your n8n instance using the JSON file: &lt;a href="https://digit11.com/blog/gcp-cost/GCP%20Billing%20costs.json" rel="noopener noreferrer"&gt;GCP Billing costs.json&lt;/a&gt;. Make sure to update the &lt;code&gt;table_name&lt;/code&gt; variable in the "Configure variables" node to match your BigQuery billing export table.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configure API Widgets
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1.&lt;/strong&gt; Download &lt;a href="https://apps.apple.com/us/app/api-widgets/id6756238482" rel="noopener noreferrer"&gt;API Widgets&lt;/a&gt; from the App Store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2.&lt;/strong&gt; Create a new widget and configure the API endpoint in the &lt;strong&gt;Source&lt;/strong&gt; tab, pointing it to your n8n webhook URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3.&lt;/strong&gt; Set up the visualization in the &lt;strong&gt;Design&lt;/strong&gt; tab.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fatj4e4p8qhz8iwml51sd.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fatj4e4p8qhz8iwml51sd.jpeg" alt="API Widgets design configuration on iPhone - screenshot" width="591" height="1280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4.&lt;/strong&gt; Add a home screen widget and link it to the one you just created.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3pw88o2hmd73ed8dmxvu.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3pw88o2hmd73ed8dmxvu.jpeg" alt="iPhone home screen with GCP cost widget - screenshot" width="591" height="1280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F74je4hb2qp3gjfci6ofc.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F74je4hb2qp3gjfci6ofc.jpeg" alt="iPhone home screen with GCP cost widget bar details - screenshot" width="800" height="848"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The widget also works on Apple Watch — view your costs at a glance from your wrist:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqkwv5qgawzdr9v8tpuat.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqkwv5qgawzdr9v8tpuat.jpeg" alt="Apple Watch home screen with GCP cost widget - screenshot" width="416" height="496"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Falqfuctdcfh9njc42f5q.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Falqfuctdcfh9njc42f5q.jpeg" alt="Apple Watch widget showing GCP cost chart - screenshot" width="416" height="496"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr9uvu9wh1vnyt5mjvj1q.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr9uvu9wh1vnyt5mjvj1q.jpeg" alt="Apple Watch widget showing cost breakdown details - screenshot" width="416" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>gcp</category>
      <category>iphone</category>
      <category>n8n</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Using Cloudflare SSL with Elastic Beanstalk instances</title>
      <dc:creator>Kamil Kujawiński</dc:creator>
      <pubDate>Sat, 04 Jan 2025 16:06:04 +0000</pubDate>
      <link>https://dev.to/kkuj/using-cloudflare-ssl-with-elastic-beanstalk-instances-gf3</link>
      <guid>https://dev.to/kkuj/using-cloudflare-ssl-with-elastic-beanstalk-instances-gf3</guid>
      <description>&lt;p&gt;Motivation:&lt;/p&gt;

&lt;p&gt;To obtain a free SSL certificate (by Let's Encrypt) for your web application without the hassle of renewing it every three months, consider the following approach.&lt;/p&gt;

&lt;p&gt;The simplest solution is to use a Load Balancer and attach an SSL certificate from AWS Certificate Manager (ACM) to it. However, relying on a &lt;strong&gt;Load Balancer solely for HTTPS may be costly&lt;/strong&gt;. To address this, I’ve outlined a much more affordable alternative that requires a bit of configuration.&lt;/p&gt;

&lt;p&gt;Additionally, in this post, I explain how to configure SSL for an Elastic Beanstalk instance, as I found the existing documentation and available resources to be somewhat outdated.&lt;/p&gt;




&lt;p&gt;Keyword of the solution is Cloudflare.&lt;/p&gt;

&lt;p&gt;Cloudflare offers a proxy feature that enables you to set up a free SSL certificate while securely proxying traffic to your host.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;Flexible&lt;/strong&gt; mode, the communication between Cloudflare and AWS is not encrypted. If you prefer end-to-end encryption, you can opt for &lt;strong&gt;Full&lt;/strong&gt; mode instead.&lt;/p&gt;

&lt;p&gt;Steps&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Add your domain to Cloudflare.
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;In the DNS settings, configure your domain as proxied.&lt;/li&gt;
&lt;li&gt;In the SSL/TLS settings, select Full (strict) mode for end-to-end encryption.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Generate an Origin Certificate in Cloudflare
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Go to the SSL/TLS &amp;gt; Origin Server section in Cloudflare.&lt;/li&gt;
&lt;li&gt;Generate a certificate valid for up to 15 years.&lt;/li&gt;
&lt;li&gt;Use this certificate to configure SSL in AWS Elastic Beanstalk.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Store Certificates in AWS Secrets Manager:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Save the &lt;code&gt;server.crt&lt;/code&gt; and &lt;code&gt;server.key&lt;/code&gt; files as secrets in AWS Secrets Manager.&lt;/li&gt;
&lt;li&gt;Use the following secret names: &lt;code&gt;/app/ssl/server-crt&lt;/code&gt; and &lt;code&gt;/app/ssl/server-key&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Grant IAM Role Access to Secrets:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;In AWS IAM add a new policy named &lt;code&gt;ReadSSLSecretKeys&lt;/code&gt; for the role &lt;code&gt;aws-elasticbeanstalk-ec2-role&lt;/code&gt; with the following permissions:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "ssm:GetParameter",
      "Resource": "arn:aws:ssm:region:account_id:parameter/app/ssl/server-crt"
    },
    {
      "Effect": "Allow",
      "Action": "ssm:GetParameter",
      "Resource": "arn:aws:ssm:region:account_id:parameter/app/ssl/server-key"
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Configure your Elastic Beanstalk deployment to listen on port &lt;code&gt;443&lt;/code&gt; and use the Origin Certificate generated in Cloudflare (stored in AWS Secrets Manager) do changes in the following files:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.ebextensions/01_https-instance-securitygroup.config&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Resources:
  sslSecurityGroupIngress:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      GroupId: {"Fn::GetAtt" : ["AWSEBSecurityGroup", "GroupId"]}
      IpProtocol: tcp
      ToPort: 443
      FromPort: 443
      CidrIp: 0.0.0.0/0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.ebextensions/02_https-instance.config&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;files:
  /etc/pki/tls/certs/server.crt:
    mode: "000400"
    owner: root
    group: root
    content: |
      -----BEGIN CERTIFICATE-----
      certificate file contents
      -----END CERTIFICATE-----

  /etc/pki/tls/certs/server.key:
    mode: "000400"
    owner: root
    group: root
    content: |
      -----BEGIN RSA PRIVATE KEY-----
      private key contents # See note below.
      -----END RSA PRIVATE KEY-----

container_commands:
  01_fetch_server_crt:
    command: |
      aws ssm get-parameter --name "/app/ssl/server-crt" --with-decryption --query "Parameter.Value" --output text &amp;gt; /etc/pki/tls/certs/server.crt
  02_fetch_server_key:
    command: |
      aws ssm get-parameter --name "/app/ssl/server-key" --with-decryption --query "Parameter.Value" --output text &amp;gt; /etc/pki/tls/certs/server.key

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.platform/nginx/conf.d/https.conf&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;server {
    listen 443 ssl;

    ssl_certificate /etc/pki/tls/certs/server.crt;
    ssl_certificate_key /etc/pki/tls/certs/server.key;

    ssl_session_timeout 5m;

    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_prefer_server_ciphers on;

    location / {
      proxy_pass http://127.0.0.1:8000;
      proxy_http_version 1.1;

      proxy_set_header Connection "";
      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Proto https;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>aws</category>
      <category>elasticbeanstalk</category>
      <category>cloudflare</category>
      <category>ssl</category>
    </item>
  </channel>
</rss>
