<?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: Elliot Marsh</title>
    <description>The latest articles on DEV Community by Elliot Marsh (@tablejourney).</description>
    <link>https://dev.to/tablejourney</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%2F4164183%2F0b89622f-c815-4c48-9438-a4bb80c14f83.png</url>
      <title>DEV Community: Elliot Marsh</title>
      <link>https://dev.to/tablejourney</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tablejourney"/>
    <language>en</language>
    <item>
      <title>Crediting a booking to the AI agent that sent it, without cookies</title>
      <dc:creator>Elliot Marsh</dc:creator>
      <pubDate>Sun, 01 Nov 2026 07:29:13 +0000</pubDate>
      <link>https://dev.to/tablejourney/crediting-a-booking-to-the-ai-agent-that-sent-it-without-cookies-116</link>
      <guid>https://dev.to/tablejourney/crediting-a-booking-to-the-ai-agent-that-sent-it-without-cookies-116</guid>
      <description>&lt;p&gt;When an AI agent recommends a food tour and the person books it, the booking platform pays a commission to whoever owns the link. We wanted that money to reach the agent, or the person or company behind it, so we opened a partner program that pays out half of what we earn. The hard part was not the payout. It was making sure each booking, reported weeks later by a partner we do not control, can be traced to the agent that sent it.&lt;/p&gt;

&lt;p&gt;This post covers how that works at TableJourney, a food travel guide with a public MCP server. None of it depends on a cookie in the traveller's browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The only thing that survives: the partner's own sub-id
&lt;/h2&gt;

&lt;p&gt;Every booking partner reports commissions per booking, and every one of them echoes back one free-text field you put on the link:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Partner&lt;/th&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GetYourGuide&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cmp&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Viator&lt;/td&gt;
&lt;td&gt;&lt;code&gt;campaign&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Musement&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cid&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Awin (hotels, car hire)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;clickref&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CJ (hotels)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sid&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That field is the whole attribution system. Anything we know at click time has to be written into it, because it is the only thing that comes back in the commission report or the network's transaction API. So each member gets a short code, and the value we write is &lt;code&gt;tjr-&amp;lt;code&amp;gt;-&amp;lt;placement&amp;gt;&lt;/code&gt;, for example &lt;code&gt;tjr-k7m2p9xq-city-hub-module&lt;/code&gt;. The prefix lets the ledger pick member lines out of everything else with one &lt;code&gt;LIKE 'tjr-%'&lt;/code&gt;, and the placement tells us which part of the page converted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agents: the link is born attributed
&lt;/h2&gt;

&lt;p&gt;An agent never sees a raw partner URL. When it calls a tool such as &lt;code&gt;experiences&lt;/code&gt; or &lt;code&gt;stays&lt;/code&gt; with its API key, every booking link in the answer is a short signed redirect on our domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://tablejourney.com/go/&amp;lt;payload&amp;gt;.&amp;lt;hmac&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payload names the key, the kind of link and the destination. The redirect checks the signature, looks up the key's campaign, writes it into that partner's sub-id field and sends a 302. The agent passes the link on unchanged and the attribution is already inside it. Nothing needs to persist on the traveller's device, which matters because the traveller may open the link in a different browser, a chat app's web view, or days later.&lt;/p&gt;

&lt;p&gt;Getting a key is two MCP calls: &lt;code&gt;partner_join&lt;/code&gt; with an email address, which sends a six-digit code, and &lt;code&gt;partner_confirm&lt;/code&gt; with that code, which returns the key and the referral code. For clients that prefer OAuth there is the usual discovery (RFC 8414 metadata, RFC 7591 dynamic registration, PKCE).&lt;/p&gt;

&lt;h2&gt;
  
  
  People: hold the code, stamp every link
&lt;/h2&gt;

&lt;p&gt;A person shares any page with &lt;code&gt;?ref=&amp;lt;code&amp;gt;&lt;/code&gt; on the end. On arrival, a first-party cookie set by our server (not by script, so browsers that cap script-written storage at seven days leave it alone) holds the code for 30 days, and the page rewrites every partner link's &lt;code&gt;href&lt;/code&gt; on load.&lt;/p&gt;

&lt;p&gt;Rewriting on load, rather than on click, is deliberate. We tried click-time tagging first and lost attribution on more than half of the links: middle-click, "open in new tab" from the context menu, long-press on a phone and "copy link address" never fire a &lt;code&gt;click&lt;/code&gt; event. So the stamp goes into the &lt;code&gt;href&lt;/code&gt; itself, and the click handler only re-checks it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The links that appear later
&lt;/h2&gt;

&lt;p&gt;Our first audit of this found a gap. Two kinds of partner links were not in the page when the load-time pass ran:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;hotel links whose dates are filled in by script with the coming weekend, which replaced the stamped &lt;code&gt;href&lt;/code&gt; with a fresh, unstamped one;&lt;/li&gt;
&lt;li&gt;cards swapped in after a location lookup ("near you") and the booking bar on phones.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Clicks still carried the code, but a long-press on iOS opened the unstamped address. The fix is a &lt;code&gt;MutationObserver&lt;/code&gt; that stamps any partner link added to the page or whose &lt;code&gt;href&lt;/code&gt; changes:&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;new&lt;/span&gt; &lt;span class="nc"&gt;MutationObserver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;nodes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;attributes&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;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addedNodes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nodeType&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a[rel~="sponsored"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nf"&gt;stamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&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;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;childList&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a[rel~="sponsored"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stamp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;observe&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;documentElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;subtree&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;childList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attributeFilter&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;href&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;rel&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;stamp&lt;/code&gt; leaves a link alone when its sub-id already starts with the member's mark, so its own write does not loop. A check across 15 live pages afterwards found the code on all 699 partner links.&lt;/p&gt;

&lt;h2&gt;
  
  
  Iframes on other people's sites
&lt;/h2&gt;

&lt;p&gt;Our food tours widget is an iframe other sites embed. It runs no script, by design, so the page inside the frame cannot stamp anything. A member who embeds it adds their code to the frame's address (&lt;code&gt;/embed/tours/italy/rome/?ref=&amp;lt;code&amp;gt;&lt;/code&gt;), and each card links to a redirect on our domain instead of straight to the partner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/go/embed?u=&amp;lt;partner link&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The click is a same-origin request from the frame's document, so under the default &lt;code&gt;strict-origin-when-cross-origin&lt;/code&gt; referrer policy the browser sends the frame's full address, query string included, as the &lt;code&gt;Referer&lt;/code&gt;. The redirect reads the code from it, checks it belongs to an active member, writes it into the sub-id and logs the click. It only accepts the two partners that allow our links on third-party sites, so it is not an open redirect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading it back
&lt;/h2&gt;

&lt;p&gt;Once a day the network APIs (Awin, CJ) return transactions with the sub-id, and the partners without one are imported from their own reports, broken down by the same field. A line with a &lt;code&gt;tjr-&lt;/code&gt; sub-id becomes a member earning at the member's share; it becomes payable only after the partner has paid us and a holding period has passed, and every payout batch is approved by a person.&lt;/p&gt;

&lt;p&gt;The program is open to agents and people: &lt;a href="https://tablejourney.com/partners/" rel="noopener noreferrer"&gt;https://tablejourney.com/partners/&lt;/a&gt;. The MCP server and the full tool list are at &lt;a href="https://tablejourney.com/agents/" rel="noopener noreferrer"&gt;https://tablejourney.com/agents/&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>affiliate</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your AI agent can earn a commission on the travel it recommends</title>
      <dc:creator>Elliot Marsh</dc:creator>
      <pubDate>Wed, 21 Oct 2026 01:18:19 +0000</pubDate>
      <link>https://dev.to/tablejourney/your-ai-agent-can-earn-a-commission-on-the-travel-it-recommends-8do</link>
      <guid>https://dev.to/tablejourney/your-ai-agent-can-earn-a-commission-on-the-travel-it-recommends-8do</guid>
      <description>&lt;p&gt;Agents already recommend restaurants, food tours and hotels all day. When a person books one of those, someone usually earns a commission. It is almost never the agent, or the developer who built it.&lt;/p&gt;

&lt;p&gt;We run &lt;a href="https://tablejourney.com" rel="noopener noreferrer"&gt;TableJourney&lt;/a&gt;, a food travel guide with a public &lt;a href="https://tablejourney.com/agents/" rel="noopener noreferrer"&gt;MCP server&lt;/a&gt; over 224 cities: verified places to eat, food festivals with resolved dates, and bookable food tours, classes and stays from GetYourGuide, Viator, Musement and hotel partners. This week we opened a partner program, and we built it for agents first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deal
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;50% of the commission we are paid&lt;/strong&gt; on bookings made by the people you send. &lt;strong&gt;60%&lt;/strong&gt; in any month where your settled earnings pass $500.&lt;/li&gt;
&lt;li&gt;Free to join. Payouts monthly by PayPal, crypto (USDC on Base or Solana, ETH, SOL or BTC) or bank transfer, from $10 ($25 in BTC, $50 by bank), 3 days after the booking partner pays us.&lt;/li&gt;
&lt;li&gt;Our booking partners have approved sub-affiliates, so this is inside their terms, not around them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why half: the share is of our commission, not of the booking price, so a small percentage of it would be a few cents a booking. Half is enough to be worth an agent's while, and our pages still do part of the selling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Joining without a browser
&lt;/h2&gt;

&lt;p&gt;Everything is MCP tools on &lt;code&gt;https://tablejourney.com/mcp&lt;/code&gt; (streamable HTTP, no key needed to read):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"partner_join"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&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="nl"&gt;"accept_terms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"My travel agent"&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;No email is needed: the answer holds an API key and a referral code at once. The key is the account, so keep it. Before the first payout, add the operator's email and confirm it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"partner_add_email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&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="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;operator inbox&amp;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="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"partner_confirm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&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="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;operator inbox&amp;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;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ABCD-2345"&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;The key stays the same. Payout details are confirmed by email, and a person or company behind the agent receives the payouts.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a booking gets attributed
&lt;/h2&gt;

&lt;p&gt;Send the key as &lt;code&gt;Authorization: Bearer &amp;lt;key&amp;gt;&lt;/code&gt; on the MCP connection. From then on every booking link the read tools return (&lt;code&gt;experiences&lt;/code&gt;, &lt;code&gt;stays&lt;/code&gt;, &lt;code&gt;tickets&lt;/code&gt;, &lt;code&gt;car_hire&lt;/code&gt; and the rest) is a &lt;code&gt;/go/&lt;/code&gt; link signed for your key, and the link carries your code in the partner's own sub-id field: &lt;code&gt;cmp&lt;/code&gt; for GetYourGuide, &lt;code&gt;campaign&lt;/code&gt; for Viator, &lt;code&gt;cid&lt;/code&gt; for Musement, &lt;code&gt;clickref&lt;/code&gt; for Awin and &lt;code&gt;sid&lt;/code&gt; for CJ. That sub-id is what each partner reports back with the commission, so attribution does not depend on a cookie surviving in somebody's browser.&lt;/p&gt;

&lt;p&gt;If your agent sends people to pages instead, &lt;code&gt;partner_link&lt;/code&gt; returns any tablejourney.com page with &lt;code&gt;?ref=&amp;lt;code&amp;gt;&lt;/code&gt;. The site holds the code for 30 days and stamps it into every partner link the visitor sees, including ones that appear after the page loads.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;partner_stats&lt;/code&gt; shows visits, booking clicks, and earnings as pending, confirmed and paid. &lt;code&gt;partner_payout_method&lt;/code&gt; sets where the money goes; it takes effect only after a code sent to the account's address, so a leaked key cannot redirect payouts.&lt;/p&gt;

&lt;h2&gt;
  
  
  For agents that prefer OAuth
&lt;/h2&gt;

&lt;p&gt;The usual discovery documents are there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt; (RFC 8414), with dynamic client registration (RFC 7591) and PKCE S256&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/.well-known/oauth-protected-resource&lt;/code&gt; (RFC 9728); a 401 from the partner API carries &lt;code&gt;WWW-Authenticate&lt;/code&gt; pointing at it&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/.well-known/agent-card.json&lt;/code&gt;, an A2A card whose food travel skill is answered by our own model from the same database&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An MCP client that handles the OAuth flow can sign the person in through the browser instead of handling a key.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we ask
&lt;/h2&gt;

&lt;p&gt;Pass booking links on unchanged, tell people they are affiliate links, and point them at the page you got the facts from. The full terms are short: &lt;a href="https://tablejourney.com/partners/terms/" rel="noopener noreferrer"&gt;https://tablejourney.com/partners/terms/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Program page: &lt;a href="https://tablejourney.com/partners/" rel="noopener noreferrer"&gt;https://tablejourney.com/partners/&lt;/a&gt;. Agent guide and the full tool list: &lt;a href="https://tablejourney.com/agents/" rel="noopener noreferrer"&gt;https://tablejourney.com/agents/&lt;/a&gt;. Questions go to &lt;a href="mailto:support@tablejourney.com"&gt;support@tablejourney.com&lt;/a&gt;, and we read them.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>agents</category>
      <category>travel</category>
    </item>
    <item>
      <title>Stop storing event dates: store the rule and compute the next edition</title>
      <dc:creator>Elliot Marsh</dc:creator>
      <pubDate>Fri, 16 Oct 2026 06:10:32 +0000</pubDate>
      <link>https://dev.to/tablejourney/stop-storing-event-dates-store-the-rule-and-compute-the-next-edition-2lgc</link>
      <guid>https://dev.to/tablejourney/stop-storing-event-dates-store-the-rule-and-compute-the-next-edition-2lgc</guid>
      <description>&lt;p&gt;Most event listings go stale in a predictable way. Someone types "18 to 21 November 2026" into a database, the event happens, and the page keeps showing last year's dates until a person notices. For one-off events that is fine. For annual events it is a bug you ship every year.&lt;/p&gt;

&lt;p&gt;At TableJourney we keep the next dates of about 1,400 food festivals. Typing dates by hand did not survive the first year, so we stopped storing dates and started storing the rule a festival follows. The site computes the next edition from the rule every day. This post shows the shapes of rule we needed, with working Python.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three kinds of recurrence
&lt;/h2&gt;

&lt;p&gt;After going through the whole dataset, almost every annual event fell into one of three groups:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fixed day of the year.&lt;/strong&gt; "Every 24 to 26 December." The easy case: store month and day, roll the year forward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nth weekday of a month.&lt;/strong&gt; "The third Thursday of November, for four days." The weekday holds, the day number moves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anchored to a moveable feast.&lt;/strong&gt; Easter, Lunar New Year, Mid-Autumn, Ramadan, Diwali. These move by weeks between years, so neither of the first two works.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A fourth shape, "the weekend after Lunar New Year", turned out to be common enough to need its own rule, and it is the one that catches people out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule as data
&lt;/h2&gt;

&lt;p&gt;Each event carries a small &lt;code&gt;date_rule&lt;/code&gt; object instead of a date. These are real rules from our data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"nth_weekday"&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Thursday"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"length"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&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="nl"&gt;"nth_weekday"&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="mi"&gt;-1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Monday"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;-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;"length"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&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="nl"&gt;"anchor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"easter"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"end_offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;-47&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="nl"&gt;"anchor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"chinese-new-year"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"start_offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"end_offset"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;14&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="nl"&gt;"anchor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"chinese-new-year"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"weekday_after"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Saturday"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"length"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&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;The second one reads "the Sunday before the last Monday of the month, for two days", which is how a lot of bank-holiday weekend events are actually defined. The month comes from the event's stored &lt;code&gt;start_month&lt;/code&gt;; the rule never contains a year, so it keeps working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nth weekday
&lt;/h2&gt;

&lt;p&gt;The helper is short. &lt;code&gt;n&lt;/code&gt; is 1 to 5, or -1 for the last one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;calendar&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;nth_weekday&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weekday&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;n-th (1..5, or -1 for last) weekday (0=Monday) of the month.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;calendar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monthrange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;month&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="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;weekday&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;weekday&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;nth_weekday&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# third Thursday of November 2026
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;nth_weekday&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2027&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# last Monday of May 2027
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-11-19
2027-05-31
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning &lt;code&gt;None&lt;/code&gt; for "the fifth Friday" in a month that has four is deliberate: the caller can then look at the next year instead of inventing a date.&lt;/p&gt;

&lt;h2&gt;
  
  
  Easter: compute it, do not table it
&lt;/h2&gt;

&lt;p&gt;Easter is the one moveable feast you can compute exactly. This is the Anonymous Gregorian algorithm, as it sits in our code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;easter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Gregorian Easter Sunday (Anonymous Gregorian algorithm).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;year&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;divmod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;year&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="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;divmod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&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="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;19&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
    &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;divmod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&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="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;451&lt;/span&gt;
    &lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;divmod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;114&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;easter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2027&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2028&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;['2026-04-05', '2027-03-28', '2028-04-16']
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Carnival, Lent markets and anything defined as "N days before Easter" become an offset from this date. The &lt;code&gt;{"anchor": "easter", "end_offset": -47}&lt;/code&gt; rule above ends a season on Shrove Tuesday, 47 days before Easter Sunday.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lunar and religious calendars: a small table, sourced
&lt;/h2&gt;

&lt;p&gt;Lunar New Year, Mid-Autumn, Ramadan, Eid and Diwali cannot be computed reliably from a formula you want to maintain. We keep a small JSON table of anchor dates per year, each from a published source (an observatory's lunar tables, published religious calendars), and the code reads it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"chinese-new-year"&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="nl"&gt;"2026"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-02-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"2027"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2027-02-06"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"2028"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2028-01-26"&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;The table has an end. When an anchor runs out of years, the resolver returns &lt;code&gt;None&lt;/code&gt; and the event keeps its stored month and day, and the data validator flags every rule whose anchor table ends too soon, so the table gets extended before it matters. A table that silently ends is worse than no table.&lt;/p&gt;

&lt;h2&gt;
  
  
  The weekend-after rule
&lt;/h2&gt;

&lt;p&gt;Here is the trap. London's Chinatown celebration happens on the first weekend on or after Lunar New Year. In 2026 the holiday is a Tuesday, so the event is the following Saturday. In 2027 the holiday is itself a Saturday. A fixed offset like "+4 days" lands on a Saturday one year and a Wednesday the next, so it cannot express this. Snap forward to a weekday instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;WEEKDAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Monday&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Tuesday&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Wednesday&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Thursday&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Friday&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Saturday&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sunday&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;snap_forward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;weekday_name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WEEKDAYS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weekday_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;weekday&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;lny_2027&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2027&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="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;snap_forward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lny_2027&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Saturday&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2027-02-06 2027-02-07
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We also support &lt;code&gt;weekday_nearest&lt;/code&gt; (the closest named weekday in either direction) for events that celebrate a holiday on the weekend nearest to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking the next edition
&lt;/h2&gt;

&lt;p&gt;With an occurrence function per rule, the next edition is the earliest occurrence that has not ended yet. Check a small window of years, because an event in early January belongs to next year when you ask in December, and a season that started last year may still be running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;next_occurrence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule_occurrence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;year&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&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="n"&gt;occ&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rule_occurrence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# (start, end) or None
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;occ&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;occ&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="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;occ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;best&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="n"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;occ&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;best&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Comparing against the end date rather than the start keeps a running event on the page as "on now" until its last day. Some events run several editions a year (restaurant weeks in winter and summer), so the real code takes a list of rules and picks the soonest across all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this bought us
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Titles, snippets and structured data always show the next real dates, with no yearly data entry.&lt;/li&gt;
&lt;li&gt;One Hong Kong page had been advertising 17 February 2027 for Lunar New Year, when the holiday is on the 6th. A rule cannot make that mistake.&lt;/li&gt;
&lt;li&gt;The same resolved dates feed everything downstream: the pages, the calendar feeds, the API and an open dataset. One computation, many outputs, no disagreements between them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule format is deliberately boring JSON so that research agents and people can both write it. When a source states a pattern in prose ("the second weekend of September"), we encode the pattern, then check that the rule reproduces the date the organiser published for this year before trusting it.&lt;/p&gt;

&lt;p&gt;If you want to see the result, the dates of all of these events are in an open dataset (CC BY 4.0) you can query or download: &lt;a href="https://tablejourney.com/festivals/statistics/" rel="noopener noreferrer"&gt;https://tablejourney.com/festivals/statistics/&lt;/a&gt; has the summary and the links. How do you handle recurring dates in your own projects? I would like to hear about rule shapes we have not needed yet.&lt;/p&gt;

</description>
      <category>python</category>
      <category>datetime</category>
      <category>opendata</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Food festival dates as open data: a free API, an MCP server and SQL</title>
      <dc:creator>Elliot Marsh</dc:creator>
      <pubDate>Mon, 05 Oct 2026 15:20:46 +0000</pubDate>
      <link>https://dev.to/tablejourney/food-festival-dates-as-open-data-a-free-api-an-mcp-server-and-sql-55f4</link>
      <guid>https://dev.to/tablejourney/food-festival-dates-as-open-data-a-free-api-an-mcp-server-and-sql-55f4</guid>
      <description>&lt;p&gt;Every year I hit the same problem planning food trips: festival listings tell you a festival exists, but not when the next one is. Pages still show last year's dates, or say "every autumn".&lt;/p&gt;

&lt;p&gt;So at TableJourney we keep the next dates of more than 1,500 food festivals in 55 countries, and we have opened the data up four ways: a REST API, an MCP server for AI assistants, a SQL copy on DoltHub and a plain CSV. All free, no sign-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the dates stay current
&lt;/h2&gt;

&lt;p&gt;A date like "the second weekend of October" goes stale every year. So we store each festival's recurrence rule rather than a date: nth weekday of a month, a fixed day, or an anchor such as Easter or the lunar calendar for Lunar New Year and Mid-Autumn markets. The next edition is computed from the rule, and each festival page carries the source we checked and the date we checked it.&lt;/p&gt;

&lt;h2&gt;
  
  
  REST: festivals between two dates
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://tablejourney.com/api/v1/festivals?country=italy&amp;amp;from=2026-10-10&amp;amp;to=2026-11-30&amp;amp;limit=3"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each result has a name, city, &lt;code&gt;starts_on&lt;/code&gt;, &lt;code&gt;ends_on&lt;/code&gt;, &lt;code&gt;month&lt;/code&gt;, whether it is annual, a short summary and a link to the page with the sources. The same API answers other trip questions, for example the signature dishes of a city and where to eat them:&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="s2"&gt;"https://tablejourney.com/api/v1/dishes?country=japan&amp;amp;city=osaka&amp;amp;limit=2"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Omurice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"osaka"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"japan"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ketchup-seasoned chicken rice wrapped in a soft folded omelette..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"where_to_eat"&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="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hokkyokusei Shinsaibashi"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://tablejourney.com/japan/osaka/casual-dining/hokkyokusei-shinsaibashi/"&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;The full list of endpoints (places, a composed food day, experiences, things to do) is in the OpenAPI docs at &lt;a href="https://tablejourney.com/api/v1/docs" rel="noopener noreferrer"&gt;https://tablejourney.com/api/v1/docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP: ask an assistant
&lt;/h2&gt;

&lt;p&gt;The same data is a remote MCP server (streamable HTTP) at &lt;code&gt;https://tablejourney.com/mcp&lt;/code&gt;. In Claude Code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http tablejourney https://tablejourney.com/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or in any MCP client config:&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;"mcpServers"&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;"tablejourney"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"streamable-http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://tablejourney.com/mcp"&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;Then ask things like "which food festivals are on in Spain the week I am there" or "plan a vegetarian food day in Lisbon on Sunday". Tools include &lt;code&gt;festivals&lt;/code&gt;, &lt;code&gt;search_places&lt;/code&gt;, &lt;code&gt;plan_day&lt;/code&gt;, &lt;code&gt;things_to_do&lt;/code&gt; and &lt;code&gt;experiences&lt;/code&gt;. Setup notes for other clients are at &lt;a href="https://tablejourney.com/agents/" rel="noopener noreferrer"&gt;https://tablejourney.com/agents/&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  SQL: query it on DoltHub
&lt;/h2&gt;

&lt;p&gt;The dataset is mirrored weekly to DoltHub, so you can query it without downloading anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;festivals&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;festivals&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;festivals&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Today that returns October (197), September (190) and November (161). Run it at &lt;a href="https://www.dolthub.com/repositories/tablejourney/food-festivals" rel="noopener noreferrer"&gt;https://www.dolthub.com/repositories/tablejourney/food-festivals&lt;/a&gt; or through DoltHub's SQL API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Or embed it: free widgets
&lt;/h2&gt;

&lt;p&gt;If you would rather show the dates than query them, the widget builder at &lt;a href="https://tablejourney.com/widgets/" rel="noopener noreferrer"&gt;https://tablejourney.com/widgets/&lt;/a&gt; makes an embed in three steps: choose a widget, search a city or country, pick the colours, then copy one line of code. There are six widgets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Festival calendar&lt;/strong&gt;: the next food festivals in a city, a country or the whole world&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Christmas markets&lt;/strong&gt;: this season's markets and the dates each opens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where to eat&lt;/strong&gt;: our top places in a city, for any topic we cover&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signature dishes&lt;/strong&gt;: what to eat in a city, with photos&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;City rank&lt;/strong&gt;: the city's place in our food festival index&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Statistics&lt;/strong&gt;: charts of the world's festivals (busiest month, weekday, length, share free to enter)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The preview shows each widget on a light and a dark site, and the colours can be light, dark or automatic, which follows each reader's own setting. Every widget is a small static page in an iframe: no cookies, no scripts, and it updates itself daily. Every place we cover has every widget, so a city with nothing dated yet shows an empty state instead of breaking your page.&lt;/p&gt;

&lt;p&gt;This is what the builder gives you for Chicago:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;iframe&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://tablejourney.com/embed/festivals/united-states/chicago/"&lt;/span&gt; &lt;span class="na"&gt;title=&lt;/span&gt;&lt;span class="s"&gt;"Food festivals in Chicago"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"100%"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"570"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"border:0;border-radius:14px;max-width:420px"&lt;/span&gt; &lt;span class="na"&gt;loading=&lt;/span&gt;&lt;span class="s"&gt;"lazy"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/iframe&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"font-size:13px;margin:6px 0 0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://tablejourney.com/united-states/chicago/festivals/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Food festivals in Chicago&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt; by TableJourney&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prefer a calendar app? Every city, country and kind of festival is also an .ics feed you can subscribe to in Google, Apple or Outlook, or import into an events calendar by URL, for example &lt;a href="https://tablejourney.com/calendar/united-states/chicago.ics" rel="noopener noreferrer"&gt;https://tablejourney.com/calendar/united-states/chicago.ics&lt;/a&gt;. The full list is at &lt;a href="https://tablejourney.com/calendar/" rel="noopener noreferrer"&gt;https://tablejourney.com/calendar/&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Download it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;CSV and JSON on GitHub: &lt;a href="https://github.com/lewismvaughan/tablejourney-food-festivals" rel="noopener noreferrer"&gt;https://github.com/lewismvaughan/tablejourney-food-festivals&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;A citable record on Zenodo: DOI 10.5281/zenodo.23142697&lt;/li&gt;
&lt;li&gt;Charts and findings (busiest month, weekday, length, share free to enter): &lt;a href="https://tablejourney.com/festivals/statistics/" rel="noopener noreferrer"&gt;https://tablejourney.com/festivals/statistics/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The licence is CC BY 4.0: use it for anything, with a credit link.&lt;/p&gt;

&lt;p&gt;If a date is wrong or a festival is missing, tell me in the comments or through the contact page; corrections from organisers are locked in so later updates cannot overwrite them.&lt;/p&gt;

</description>
      <category>opendata</category>
      <category>api</category>
      <category>mcp</category>
      <category>travel</category>
    </item>
  </channel>
</rss>
