<?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: Jeffrey Inman</title>
    <description>The latest articles on DEV Community by Jeffrey Inman (@jeffreyinman).</description>
    <link>https://dev.to/jeffreyinman</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%2F4144909%2F4f6145a2-c381-4ce2-be84-942be5068a32.png</url>
      <title>DEV Community: Jeffrey Inman</title>
      <link>https://dev.to/jeffreyinman</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jeffreyinman"/>
    <language>en</language>
    <item>
      <title>Fixing "Sign in to confirm you're not a bot" for YouTube transcripts on a server</title>
      <dc:creator>Jeffrey Inman</dc:creator>
      <pubDate>Sat, 17 Oct 2026 03:56:08 +0000</pubDate>
      <link>https://dev.to/jeffreyinman/fixing-sign-in-to-confirm-youre-not-a-bot-for-youtube-transcripts-on-a-server-3i1b</link>
      <guid>https://dev.to/jeffreyinman/fixing-sign-in-to-confirm-youre-not-a-bot-for-youtube-transcripts-on-a-server-3i1b</guid>
      <description>&lt;p&gt;Your transcript code works on your laptop. You deploy it to AWS, Vercel, Render, Railway or a VPS, and every video fails with one of these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: [youtube] dQw4w9WgXcQ: Sign in to confirm you're not a bot. Use --cookies-from-browser or --cookies for the authentication.
youtube_transcript_api._errors.RequestBlocked / IpBlocked
playabilityStatus: {"status": "LOGIN_REQUIRED", "reason": "Sign in to confirm you're not a bot"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is wrong with your code. YouTube decided the request came from a bot because of where it came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it only happens on servers
&lt;/h2&gt;

&lt;p&gt;YouTube scores each request by its IP address. Home and mobile connections look like people. Cloud and datacenter ranges (AWS, Google Cloud, Azure, Hetzner, DigitalOcean, the functions behind Vercel) look like scrapers, so YouTube asks them to sign in before it serves the player data that captions come from. A few requests a day may get through. Past a few hundred, most won't. That's why it looks random at first and then fails on every video.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is it the bot check, or is the video really private?
&lt;/h2&gt;

&lt;p&gt;Both come back as &lt;code&gt;LOGIN_REQUIRED&lt;/code&gt;, which is what makes this confusing. Check the reason text:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reason text&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Retry?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sign in to confirm you're not a bot&lt;/td&gt;
&lt;td&gt;Your IP is flagged, the video is fine&lt;/td&gt;
&lt;td&gt;Yes, from another IP or client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;This video is private / Sign in to confirm your age / members-only&lt;/td&gt;
&lt;td&gt;The video really needs an account&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video unavailable&lt;/td&gt;
&lt;td&gt;Deleted or blocked in your region&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Checking that string before you retry saves you from burning proxy bandwidth on videos that will never work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixes, from free to paid
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Update yt-dlp and give it a JavaScript runtime
&lt;/h3&gt;

&lt;p&gt;Recent yt-dlp versions need an external JavaScript runtime to talk to YouTube. Without one, extraction fails in ways that can look like the bot check. Install Node.js or Deno, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-U&lt;/span&gt; &lt;span class="s2"&gt;"yt-dlp[default]"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'--js-runtimes node'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.config/yt-dlp/config
yt-dlp &lt;span class="nt"&gt;--write-subs&lt;/span&gt; &lt;span class="nt"&gt;--write-auto-subs&lt;/span&gt; &lt;span class="nt"&gt;--sub-langs&lt;/span&gt; &lt;span class="s2"&gt;"en.*"&lt;/span&gt; &lt;span class="nt"&gt;--skip-download&lt;/span&gt; &lt;span class="s2"&gt;"https://youtu.be/dQw4w9WgXcQ"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it was the missing runtime, this fixes it. If it was the IP, it won't.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Ask for the player data as a different client
&lt;/h3&gt;

&lt;p&gt;YouTube serves several apps (web, mobile, TV) and doesn't apply the bot check the same way to all of them, so a video blocked for one client often loads for another. In yt-dlp that's &lt;code&gt;--extractor-args "youtube:player_client=..."&lt;/code&gt;. The yt-dlp wiki lists which clients currently work, and that list changes. For captions, the MIT-licensed &lt;a href="https://github.com/inmanjeffrey89-cell/yt-transcript-cli" rel="noopener noreferrer"&gt;yt-transcript-cli&lt;/a&gt; does this automatically (Android, then iOS, then web):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx yt-transcript-cli &lt;span class="s2"&gt;"https://youtu.be/dQw4w9WgXcQ"&lt;/span&gt; &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exits with code 2 and &lt;code&gt;BLOCKED&lt;/code&gt; when every client is refused, and code 3 when the video simply has no captions, so a script can tell the two apart. On a heavily flagged IP every client can be refused, and then you need a different IP.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Cookies from a logged-in account
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yt-dlp &lt;span class="nt"&gt;--cookies&lt;/span&gt; cookies.txt &lt;span class="nt"&gt;--write-auto-subs&lt;/span&gt; &lt;span class="nt"&gt;--skip-download&lt;/span&gt; &lt;span class="s2"&gt;"URL"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is what the error message suggests, and it works for a while. But cookies expire, every request is tied to a real Google account, and that account can get restricted for automated use. Use a throwaway account, never your main one.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Proof-of-origin tokens
&lt;/h3&gt;

&lt;p&gt;yt-dlp supports PO tokens through provider plugins. They can clear the check without cookies, but it's one more moving part that YouTube changes regularly. The yt-dlp wiki's PO Token Guide covers the setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Residential proxies
&lt;/h3&gt;

&lt;p&gt;Residential proxies make your requests look like home connections. &lt;code&gt;youtube-transcript-api&lt;/code&gt; supports them directly:&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;from&lt;/span&gt; &lt;span class="n"&gt;youtube_transcript_api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;YouTubeTranscriptApi&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;youtube_transcript_api.proxies&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;GenericProxyConfig&lt;/span&gt;

&lt;span class="n"&gt;ytt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;YouTubeTranscriptApi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proxy_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;GenericProxyConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;https_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://USER:PASS@your-residential-proxy:PORT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ytt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dQw4w9WgXcQ&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;languages&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;en&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's also a &lt;code&gt;WebshareProxyConfig&lt;/code&gt; helper. This is the reliable fix and also the expensive one. One developer who hit the block at a few hundred requests a day reported going through about $200 of residential proxy in a month, and still had to babysit the retry logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. A hosted transcript API
&lt;/h3&gt;

&lt;p&gt;If transcripts are one step in your product rather than the product, it's usually cheaper to call a service that already handles client rotation, residential retries and the reason-string checks. Disclosure: I run two versions of one.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://apify.com/beacondata/youtube-transcript-scraper" rel="noopener noreferrer"&gt;YouTube Transcript Scraper on Apify&lt;/a&gt;: $2 per 1,000 transcripts, videos without captions aren't charged, and Apify's free monthly credit covers about 2,500.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://rapidapi.com/beacondata/api/youtube-transcript-api23" rel="noopener noreferrer"&gt;YouTube Transcript API on RapidAPI&lt;/a&gt;: one GET request returning JSON, plain text or SRT. 100 free requests a month.
&lt;/li&gt;
&lt;/ul&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://youtube-transcript-api23.p.rapidapi.com/transcript?url=https://youtu.be/dQw4w9WgXcQ&amp;amp;format=text"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"x-rapidapi-key: YOUR_RAPIDAPI_KEY"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"x-rapidapi-host: youtube-transcript-api23.p.rapidapi.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Other hosted options exist too. Whichever you pick, check that failed or caption-less videos aren't billed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one to use
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Side project, under ~100 videos a day:&lt;/strong&gt; fixes 1 and 2. Often enough on their own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Steady volume and you don't mind maintaining it:&lt;/strong&gt; 2 plus residential proxies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transcripts feed an app or an AI agent and you'd rather not maintain scraping:&lt;/strong&gt; a hosted API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these is permanent. YouTube changes its checks every few weeks, so whatever you pick, make your code tell "blocked, retry later" apart from "this video has no transcript". Then it fails loudly instead of quietly returning nothing.&lt;/p&gt;

</description>
      <category>youtube</category>
      <category>python</category>
      <category>webdev</category>
      <category>api</category>
    </item>
    <item>
      <title>Live USPS rates after Web Tools: the 60-calls-an-hour limit</title>
      <dc:creator>Jeffrey Inman</dc:creator>
      <pubDate>Tue, 29 Sep 2026 20:56:49 +0000</pubDate>
      <link>https://dev.to/jeffreyinman/live-usps-rates-after-web-tools-the-60-calls-an-hour-limit-1ppg</link>
      <guid>https://dev.to/jeffreyinman/live-usps-rates-after-web-tools-the-60-calls-an-hour-limit-1ppg</guid>
      <description>&lt;p&gt;If your WooCommerce shop quotes live USPS rates, the switch from USPS Web Tools to the new USPS APIs changed two things that don't show up until a busy day: how you get keys, and how many times an hour you're allowed to ask for a price. Here's what I found wiring a store to the new API, with real numbers.&lt;/p&gt;

&lt;p&gt;Disclosure: I built Beacon Shipping Rates for USPS, a free WooCommerce plugin for this, with a paid add-on. Everything below applies whatever plugin you use.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;USPS retired the old Web Tools APIs on January 25, 2026. Plugins that still called them stopped getting rates that day. The replacement is the USPS APIs at developers.usps.com: OAuth tokens, JSON, and a different set of endpoints for domestic and international prices.&lt;/p&gt;

&lt;p&gt;Getting keys is simpler than it used to be. Sign in with your usps.com login, open Apps, add an app, tick Default Access, and copy the Consumer Key and Secret. The secret is shown once. You don't need a shipping account or an EPS account just to quote retail prices.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that bites: 60 calls an hour
&lt;/h2&gt;

&lt;p&gt;Each app gets a default quota of 60 calls per hour per API. A checkout that asks USPS for a price every time a customer changes their ZIP or quantity can burn through that in a sale, and after that USPS answers 429 and your checkout shows no USPS options at all.&lt;/p&gt;

&lt;p&gt;You can ask USPS for more through the "Email Us" page on the developer portal. Until they say yes, the fix is on your side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache identical packages.&lt;/strong&gt; Same origin ZIP, destination ZIP, weight and box size means the same price. Round the weight to a tenth of a pound and the dimensions to half an inch and most carts land on a cached answer. USPS prices change a few times a year, so a day is safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count your own calls&lt;/strong&gt; and stop just under the quota instead of waiting for the 429.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a stale copy&lt;/strong&gt; for when USPS is down, and serve it rather than nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Have a fallback rate.&lt;/strong&gt; A flat price is better than an empty shipping list.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What real prices look like
&lt;/h2&gt;

&lt;p&gt;Retail prices from the new API on September 24, 2026, 2 lb in a 10×8×4 box, Beverly Hills (90210) to Manhattan (10001), zone 8:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;service&lt;/th&gt;
&lt;th&gt;price&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;USPS Ground Advantage&lt;/td&gt;
&lt;td&gt;$19.05&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Priority Mail&lt;/td&gt;
&lt;td&gt;$21.55&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Priority Mail Express&lt;/td&gt;
&lt;td&gt;$72.45&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A flat $12 shipping charge would have lost $7 on that order and overcharged a local one. That gap is the whole reason to quote live.&lt;/p&gt;

&lt;p&gt;Puerto Rico, Alaska, Hawaii and APO/FPO ZIPs all come back as normal domestic rates. Flat-rate envelopes and boxes come back in the same response, but only offer them if the order actually fits the container.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more change
&lt;/h2&gt;

&lt;p&gt;Address validation moved behind a separate license. Since August 1, 2026, the Addresses API returns 403 unless you apply for it in the business portal (My Account, API Licenses). If a plugin promises address validation on the default keys, it won't work.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're on WooCommerce
&lt;/h2&gt;

&lt;p&gt;The official USPS Shipping Method supports the new API from version 5.2.5 and costs $109 a year. &lt;a href="https://wordpress.org/plugins/beacon-shipping-rates-for-usps/" rel="noopener noreferrer"&gt;Beacon Shipping Rates for USPS&lt;/a&gt; is free on wordpress.org and does the caching, quota guard, stale copies and fallback described above, with a setup page that tests your keys and fetches a real rate before you touch a shipping zone.&lt;/p&gt;

&lt;p&gt;Whatever you use, turn on debug logging for a day after switching and look for 429s.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>woocommerce</category>
      <category>ecommerce</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Hourly and multi-day rentals on Wix: what Wix Bookings can't do, and your options</title>
      <dc:creator>Jeffrey Inman</dc:creator>
      <pubDate>Tue, 29 Sep 2026 17:29:46 +0000</pubDate>
      <link>https://dev.to/jeffreyinman/hourly-and-multi-day-rentals-on-wix-what-wix-bookings-cant-do-and-your-options-3joe</link>
      <guid>https://dev.to/jeffreyinman/hourly-and-multi-day-rentals-on-wix-what-wix-bookings-cant-do-and-your-options-3joe</guid>
      <description>&lt;p&gt;If you rent out kayaks, bikes, boats or golf carts and your site is on Wix, you've probably tried Wix Bookings first. It's free and it's already there. It also wasn't built for rentals, and you can see that on a lot of real booking pages. Here's what goes wrong and what your options are.&lt;/p&gt;

&lt;p&gt;Disclosure: I built one of the options below (Beacon Bookings). The rest of the facts come from Wix's own help pages and the App Market, so check them yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Wix Bookings is built for
&lt;/h2&gt;

&lt;p&gt;Wix Bookings sells appointments, classes and courses. An appointment is a service with a fixed length that one client books. A class has a set time and a number of spots. That's a good fit for a haircut, a yoga class or a guided tour at 10am.&lt;/p&gt;

&lt;p&gt;A rental is different. The customer decides how long they want it, often how many of it they want, and the price usually drops for longer rentals. Wix's help center even has a feature request page for "allowing clients to set the duration of a service", which tells you it isn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workarounds I keep seeing
&lt;/h2&gt;

&lt;p&gt;I went through a few dozen rental businesses' Wix booking pages. The same patterns come up again and again:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A service per length.&lt;/strong&gt; "Kayak 2 hours", "Kayak 4 hours", "Kayak 8 hours", repeated for every boat. One pontoon company I looked at had two boats times three lengths, so six services for two boats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A service per group size.&lt;/strong&gt; "Group of 2", "Group of 3", all the way to "Group of 10", each at a different price.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An "Additional Day" service&lt;/strong&gt; that the customer is told to book together with the first day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The deposit as its own service&lt;/strong&gt; the customer has to remember to add.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-day rentals set up as 15-minute appointments&lt;/strong&gt;, because an appointment can't span two days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two identical boats listed as "Boat A" and "Boat B"&lt;/strong&gt;, because a service can't say "we have two of these".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of these work, sort of. They also mean the customer does the math, the calendar doesn't know how many you have left, and you check every booking by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your options
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Keep Wix Bookings and live with the workarounds.&lt;/strong&gt; Free, already installed, and fine if you rent one thing for one fixed length.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Wix Rentals.&lt;/strong&gt; Wix now has its own rentals app. It lets customers pick how long, priced per hour or per day, with buffer time between bookings. Two catches from Wix's own overview page: it's "available only on Wix Harmony" (Wix's newest editor), and online reservations need a Business Premium plan. Hourly rentals run from 30 minutes to 24 hours and daily rentals from 1 to 8 days, going by Wix's developer docs. I couldn't find a way to book several of the same item in one booking. If your site is on Harmony, look at it first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. An outside booking system.&lt;/strong&gt; FareHarbor, Peek, Checkfront, Booqable and similar tools are full rental and tour platforms. You link to them or embed them, and the booking and payment happen in their system, not your Wix store. Worth it if you want waivers, check-in, staff scheduling and so on. It's also a second system to pay for and learn.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. A rental app from the Wix App Market.&lt;/strong&gt; There are a few. TWICE Rentals is the most established, with a 14-day trial (2.6 stars from 18 reviews when I checked). Mine is below.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built: Beacon Bookings
&lt;/h2&gt;

&lt;p&gt;Beacon Bookings is a Wix app that adds a booking form to your site and checks out through your normal Wix checkout. It works in the regular Wix Editor and Wix Studio. What it does for rentals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Customers pick &lt;strong&gt;how long&lt;/strong&gt; (by the hour, day or night) and &lt;strong&gt;how many&lt;/strong&gt; ("Kayaks: 2").&lt;/li&gt;
&lt;li&gt;An hourly rental can &lt;strong&gt;run overnight or for days&lt;/strong&gt;, so one form covers 2 hours, a full day and a week. Pickup and return just have to fall in your open hours.&lt;/li&gt;
&lt;li&gt;You set &lt;strong&gt;capacity&lt;/strong&gt;: 6 kayaks means the 7th booking for that time is refused.&lt;/li&gt;
&lt;li&gt;Customers can put &lt;strong&gt;several items in one order&lt;/strong&gt; (two kayaks and a paddle board, or a bounce house and 40 chairs) and pay once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set prices for longer rentals&lt;/strong&gt;: "4 hours for $120" or "7 days for $300". The customer is charged whichever works out lower.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A first-hour-plus-extra-hours&lt;/strong&gt; price works too (a per-booking base price plus an hourly rate).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buffers&lt;/strong&gt;, weekly hours and closed dates.&lt;/li&gt;
&lt;li&gt;The slot is &lt;strong&gt;held while the customer pays&lt;/strong&gt;, so two people don't grab the last boat.&lt;/li&gt;
&lt;li&gt;On the paid plan, a &lt;strong&gt;refundable security deposit&lt;/strong&gt; is added to the checkout as its own line, so you can refund exactly that amount from the order later.&lt;/li&gt;
&lt;li&gt;The booking form shows in your site's language (English, Spanish, Portuguese, French or German).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's free for one rental type. The paid plan is $12 a month for unlimited rental types, resources (rooms, staff, specific boats) and deposits. It doesn't do waivers or add-on items like coolers yet.&lt;/p&gt;

&lt;p&gt;It's on the Wix App Market: &lt;a href="https://www.wix.com/app-market/2bc1c22c-3f92-4382-9409-019d676ada13" rel="noopener noreferrer"&gt;Beacon Bookings&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One item, one fixed length: Wix Bookings is fine.&lt;/li&gt;
&lt;li&gt;Your site is on Wix Harmony: try Wix Rentals.&lt;/li&gt;
&lt;li&gt;You need waivers, check-in and staff tools: an outside system like FareHarbor or Peek.&lt;/li&gt;
&lt;li&gt;You're on the regular Wix Editor and want customers to pick how long and how many, pay through your own checkout, and never overbook: that's the gap Beacon Bookings fills.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>wix</category>
      <category>webdev</category>
      <category>smallbusiness</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Moving off EventON: its timestamps aren't what they look like</title>
      <dc:creator>Jeffrey Inman</dc:creator>
      <pubDate>Tue, 29 Sep 2026 01:00:46 +0000</pubDate>
      <link>https://dev.to/jeffreyinman/moving-off-eventon-its-timestamps-arent-what-they-look-like-1cpi</link>
      <guid>https://dev.to/jeffreyinman/moving-off-eventon-its-timestamps-arent-what-they-look-like-1cpi</guid>
      <description>&lt;p&gt;EventON is a popular WordPress events calendar, sold on CodeCanyon, with a free Lite version on wordpress.org. If you're moving off it, or just exporting your events, there's one thing to understand before anything else: the start and end times look like normal unix timestamps, and they aren't. Convert them the obvious way and every event moves by your site's UTC offset.&lt;/p&gt;

&lt;p&gt;I read this from EventON Lite 2.5.9, mostly from the function that turns the date fields on the edit screen into stored values. As far as I can tell the paid version uses the same post type and fields, but check a few of your own events against what's below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where your events live
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Each event is a post of type &lt;code&gt;ajde_events&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The times are in &lt;code&gt;evcal_srow&lt;/code&gt; (start) and &lt;code&gt;evcal_erow&lt;/code&gt; (end), as unix timestamps.&lt;/li&gt;
&lt;li&gt;The event's timezone is in &lt;code&gt;_evo_tz&lt;/code&gt;. All-day events have &lt;code&gt;evcal_allday = "yes"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Repeats are in &lt;code&gt;evcal_repeat&lt;/code&gt;, &lt;code&gt;evcal_rep_freq&lt;/code&gt;, &lt;code&gt;evcal_rep_gap&lt;/code&gt; and a few more fields, plus a list called &lt;code&gt;repeat_intervals&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Locations and organizers are the &lt;code&gt;event_location&lt;/code&gt; and &lt;code&gt;event_organizer&lt;/code&gt; taxonomies.&lt;/li&gt;
&lt;li&gt;Event types are &lt;code&gt;event_type&lt;/code&gt;, and extra type sets become &lt;code&gt;event_type_2&lt;/code&gt;, &lt;code&gt;event_type_3&lt;/code&gt; and so on.&lt;/li&gt;
&lt;li&gt;The event colour is &lt;code&gt;evcal_event_color&lt;/code&gt;, stored as hex without the &lt;code&gt;#&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Status is &lt;code&gt;_status&lt;/code&gt;: scheduled, cancelled, rescheduled, postponed, movedonline, tentative or preliminary. The online link is in &lt;code&gt;_vir_url&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Trap 1: the timestamps are "virtual"
&lt;/h2&gt;

&lt;p&gt;When you save an event for 6:30 pm, EventON builds the timestamp in UTC from the wall-clock time you typed. So &lt;code&gt;evcal_srow&lt;/code&gt; is 18:30 UTC, whatever your timezone is. It's the local time pretending to be UTC.&lt;/p&gt;

&lt;p&gt;That means this is wrong on any site that isn't on UTC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wrong: shifts the event by the site's offset.&lt;/span&gt;
&lt;span class="nv"&gt;$start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Y-m-d H:i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;get_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'evcal_srow'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and this is right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The stored number is wall-clock time encoded as UTC, so read it back as UTC.&lt;/span&gt;
&lt;span class="nv"&gt;$start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;gmdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Y-m-d H:i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;get_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'evcal_srow'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$zone&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_evo_tz'&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="o"&gt;?:&lt;/span&gt; &lt;span class="nf"&gt;wp_timezone_string&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same goes for SQL. &lt;code&gt;FROM_UNIXTIME()&lt;/code&gt; converts with the session's time zone, so set it to UTC first or you get the shift there too:&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;SET&lt;/span&gt; &lt;span class="n"&gt;time_zone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'+00:00'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FROM_UNIXTIME&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meta_value&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;starts_local&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wp_posts&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;wp_postmeta&lt;/span&gt; &lt;span class="n"&gt;pm&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meta_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'evcal_srow'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ajde_events'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_status&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'trash'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'auto-draft'&lt;/span&gt;&lt;span class="p"&gt;)&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;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meta_value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Trap 2: trust &lt;code&gt;repeat_intervals&lt;/code&gt;, not the settings
&lt;/h2&gt;

&lt;p&gt;A repeating event stores its settings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;how often: &lt;code&gt;evcal_rep_freq&lt;/code&gt; = hourly, daily, weekly, monthly, yearly or custom&lt;/li&gt;
&lt;li&gt;the gap&lt;/li&gt;
&lt;li&gt;for weekly, the chosen days in &lt;code&gt;evo_rep_WKwk&lt;/code&gt; (0 = Sunday)&lt;/li&gt;
&lt;li&gt;for monthly, "by weekday" in &lt;code&gt;evo_rep_WK&lt;/code&gt; with the week of the month in &lt;code&gt;evo_repeat_wom&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also stores &lt;code&gt;repeat_intervals&lt;/code&gt;: every occurrence it generated, as &lt;code&gt;[start, end]&lt;/code&gt; pairs of the same virtual timestamps. That list is what the calendar actually shows.&lt;/p&gt;

&lt;p&gt;Rebuilding a rule from the settings mostly works, but not always. Custom repeats are only a list, and hourly repeats don't map to most calendar formats. If you rebuild a rule, expand it and compare the dates against &lt;code&gt;repeat_intervals&lt;/code&gt; before you trust it. When they don't match, the list is the truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: location details aren't in term meta
&lt;/h2&gt;

&lt;p&gt;Location and organizer details (address, coordinates, contact) aren't in &lt;code&gt;wp_termmeta&lt;/code&gt;. They're in one big serialized option, &lt;code&gt;evo_tax_meta&lt;/code&gt;, keyed by taxonomy and term ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$meta&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'evo_tax_meta'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$address&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'event_location'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt; &lt;span class="nv"&gt;$term_id&lt;/span&gt; &lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'location_address'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Older sites may also have a per-term option named &lt;code&gt;taxonomy_&amp;lt;term id&amp;gt;&lt;/code&gt;, which EventON reads as a fallback. Check both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 4: types come in numbered sets
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;event_type&lt;/code&gt; is the first set of labels; turning on more creates &lt;code&gt;event_type_2&lt;/code&gt;, &lt;code&gt;event_type_3&lt;/code&gt;, each with its own URL (&lt;code&gt;/event-type_2/&amp;lt;slug&amp;gt;/&lt;/code&gt;). Decide where each set goes, and redirect all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  URLs
&lt;/h2&gt;

&lt;p&gt;Single events default to &lt;code&gt;/events/&amp;lt;slug&amp;gt;/&lt;/code&gt; (changeable in EventON's settings). Taxonomy pages are &lt;code&gt;/event-location/&amp;lt;slug&amp;gt;/&lt;/code&gt;, &lt;code&gt;/event-organizer/&amp;lt;slug&amp;gt;/&lt;/code&gt; and &lt;code&gt;/event-type/&amp;lt;slug&amp;gt;/&lt;/code&gt;, and each extra type set gets its own base. Note what yours are before you switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you switch
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Convert one event by hand with &lt;code&gt;gmdate()&lt;/code&gt; and compare it with what EventON shows on the front end.&lt;/li&gt;
&lt;li&gt;Count repeating events and compare each rebuilt rule with &lt;code&gt;repeat_intervals&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Export &lt;code&gt;evo_tax_meta&lt;/code&gt; along with your posts, or your venues lose their addresses.&lt;/li&gt;
&lt;li&gt;List every &lt;code&gt;event_type*&lt;/code&gt; taxonomy and its URL base.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Disclosure
&lt;/h2&gt;

&lt;p&gt;I build Beacon Events, a free events calendar on wordpress.org. Version 0.12.0 imports from EventON and EventON Lite and handles all four traps: &lt;a href="https://wordpress.org/plugins/beacon-events/" rel="noopener noreferrer"&gt;https://wordpress.org/plugins/beacon-events/&lt;/a&gt; . It reads the virtual timestamps correctly, keeps a repeat rule only when it reproduces EventON's stored dates exactly, pulls addresses from &lt;code&gt;evo_tax_meta&lt;/code&gt;, turns every type set into categories, and redirects the old location, organizer, type and event links. Custom date lists and hourly repeats come across as their first date, and the import report lists those events so you can check them.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>mysql</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Moving off WP Event Manager: what's in your database and what breaks</title>
      <dc:creator>Jeffrey Inman</dc:creator>
      <pubDate>Tue, 29 Sep 2026 00:53:20 +0000</pubDate>
      <link>https://dev.to/jeffreyinman/moving-off-wp-event-manager-whats-in-your-database-and-what-breaks-1b6m</link>
      <guid>https://dev.to/jeffreyinman/moving-off-wp-event-manager-whats-in-your-database-and-what-breaks-1b6m</guid>
      <description>&lt;p&gt;WP Event Manager is on about 20,000 sites. The core plugin is free; recurring events, ticket sales and registrations are paid add-ons. If you're leaving it, or just want your events out in a form another tool can read, here's how it stores them. I read this from version 3.4.1, mostly the code that saves an event from the admin screen and the front-end form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where your events live
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Each event is a post of type &lt;code&gt;event_listing&lt;/code&gt;. The title, description and featured image are where you'd expect.&lt;/li&gt;
&lt;li&gt;Dates: &lt;code&gt;_event_start_date&lt;/code&gt; and &lt;code&gt;_event_end_date&lt;/code&gt; hold the date and time together in local time (&lt;code&gt;2026-10-17 10:00:00&lt;/code&gt;). Use those rather than the separate time fields.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;_event_timezone&lt;/code&gt;: the event's own zone (see trap 4 before you use it).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;_event_online&lt;/code&gt;: "yes" or "no".&lt;/li&gt;
&lt;li&gt;The address: &lt;code&gt;_event_location&lt;/code&gt;, &lt;code&gt;_event_pincode&lt;/code&gt; and &lt;code&gt;_event_country&lt;/code&gt;, stored on the event itself.&lt;/li&gt;
&lt;li&gt;Venues and organizers are their own post types, &lt;code&gt;event_venue&lt;/code&gt; and &lt;code&gt;event_organizer&lt;/code&gt;. Events point at them by ID in &lt;code&gt;_event_venue_ids&lt;/code&gt; and &lt;code&gt;_event_organizer_ids&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Categories are the &lt;code&gt;event_listing_category&lt;/code&gt; taxonomy. Event types are a second taxonomy, &lt;code&gt;event_listing_type&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The banner image URL is in &lt;code&gt;_event_banner&lt;/code&gt;. The ticket choice and price are in &lt;code&gt;_event_ticket_options&lt;/code&gt; and &lt;code&gt;_event_ticket_price&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The traps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The address belongs to the event, not the venue.&lt;/strong&gt; A venue post has a name, a description and some links, but no address. Two events at "The Loft" can carry two different addresses. If you migrate venues on their own you lose every address; build each venue's address from its events instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Dates are usually &lt;code&gt;Y-m-d H:i:s&lt;/code&gt;, but not always.&lt;/strong&gt; When the plugin can't parse a date on save it stores what was typed instead, in your site's date format. Check for those rows before you trust a straight copy:&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="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meta_value&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;start_date&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wp_posts&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;wp_postmeta&lt;/span&gt; &lt;span class="n"&gt;pm&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meta_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'_event_start_date'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'event_listing'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_status&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'trash'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'auto-draft'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meta_value&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;REGEXP&lt;/span&gt; &lt;span class="s1"&gt;'^[0-9]{4}-[0-9]{2}-[0-9]{2}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Past events have their own status.&lt;/strong&gt; Once an event is over it becomes &lt;code&gt;expired&lt;/code&gt;, a post status the plugin registers itself. Deactivate the plugin and WordPress no longer knows that status, so the admin screens and anything built on &lt;code&gt;WP_Query&lt;/code&gt; stop seeing those posts, even though they're still in the table. Query &lt;code&gt;wp_posts&lt;/code&gt; directly, or export before you deactivate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. &lt;code&gt;_event_timezone&lt;/code&gt; doesn't always mean anything.&lt;/strong&gt; The plugin only uses it when its timezone setting (&lt;code&gt;event_manager_timezone_setting&lt;/code&gt;) is &lt;code&gt;each_event&lt;/code&gt;. On the default, &lt;code&gt;site_timezone&lt;/code&gt;, events show in the site's zone whatever that field says. So check the option before you apply the field, or a leftover value will move events by hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. The ID fields change shape.&lt;/strong&gt; &lt;code&gt;_event_organizer_ids&lt;/code&gt; can be an array, a single number, or a comma-separated string, depending on how the event was saved. &lt;code&gt;_event_venue_ids&lt;/code&gt; is usually one ID. Normalise them before you look anything up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;wpem_ids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;preg_split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/\s*,\s*/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;array_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;array_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'intval'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$list&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;6. Event types are a second set of labels.&lt;/strong&gt; They're separate from categories, with their own &lt;code&gt;/event-type/&amp;lt;slug&amp;gt;/&lt;/code&gt; pages. Decide whether they become categories or tags in the new system, and redirect those URLs to match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Add-ons keep their own data.&lt;/strong&gt; Ticket sales and registrations from the paid add-ons don't live on the event post. Migrate or archive them separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  URLs
&lt;/h2&gt;

&lt;p&gt;The defaults are &lt;code&gt;/event/&amp;lt;slug&amp;gt;/&lt;/code&gt;, &lt;code&gt;/event-category/&amp;lt;slug&amp;gt;/&lt;/code&gt;, &lt;code&gt;/event-type/&amp;lt;slug&amp;gt;/&lt;/code&gt;, and the &lt;code&gt;/events/&lt;/code&gt; archive. All three bases can be changed under the plugin's permalink settings, so check yours before you plan redirects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you switch
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Run the date query above and fix any rows it returns.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;event_manager_timezone_setting&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Export while the plugin is still active, or read the table directly, so expired events come along.&lt;/li&gt;
&lt;li&gt;Write down your three URL bases.&lt;/li&gt;
&lt;li&gt;Decide where event types go.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Disclosure
&lt;/h2&gt;

&lt;p&gt;I build Beacon Events, a free events calendar on wordpress.org, and its importer handles all of this as of 0.11.1: &lt;a href="https://wordpress.org/plugins/beacon-events/" rel="noopener noreferrer"&gt;https://wordpress.org/plugins/beacon-events/&lt;/a&gt; . It builds venues with the address from their events, brings expired events across as past events, follows the timezone setting, and turns event types into categories with their /event-type/ links redirected. Its /event/ and /event-category/ URLs are the same as the plugin's defaults, so most links keep working. It doesn't import ticket sales or registrations from the paid add-ons.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>mysql</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Moving off Modern Events Calendar: what's in your database and what breaks</title>
      <dc:creator>Jeffrey Inman</dc:creator>
      <pubDate>Tue, 29 Sep 2026 00:43:09 +0000</pubDate>
      <link>https://dev.to/jeffreyinman/moving-off-modern-events-calendar-whats-in-your-database-and-what-breaks-mpn</link>
      <guid>https://dev.to/jeffreyinman/moving-off-modern-events-calendar-whats-in-your-database-and-what-breaks-mpn</guid>
      <description>&lt;p&gt;Modern Events Calendar Lite was closed on wordpress.org on May 11, 2022, permanently, for a guideline violation (that's what its plugin page still says). Plenty of sites still run it, updated from the developer's own site. Whether you're moving to something else or just want your events out, it helps to know how MEC stores them, because a few of its choices will quietly give you the wrong dates if you copy fields as they look.&lt;/p&gt;

&lt;p&gt;I read all of this from MEC Lite 6.5.6, the last version on wordpress.org, mostly from the function that saves an event. Newer builds still have to read events saved by older ones, so the stored format should be the same, but check your own data with the queries below before you trust any of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where your events live
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Each event is a post of type &lt;code&gt;mec-events&lt;/code&gt;: title, content, excerpt, featured image.&lt;/li&gt;
&lt;li&gt;The schedule is in post meta:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mec_start_date&lt;/code&gt; / &lt;code&gt;mec_end_date&lt;/code&gt; (Y-m-d)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mec_start_day_seconds&lt;/code&gt; / &lt;code&gt;mec_end_day_seconds&lt;/code&gt; (seconds after midnight)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mec_allday&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mec_timezone&lt;/code&gt; ("global" means the site's timezone)&lt;/li&gt;
&lt;li&gt;the repeat settings in &lt;code&gt;mec_repeat_*&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;There are two tables, &lt;code&gt;wp_mec_events&lt;/code&gt; and &lt;code&gt;wp_mec_dates&lt;/code&gt;. MEC fills them from the meta when an event is saved and uses them for its calendar queries. Read the meta; treat the tables as derived.&lt;/li&gt;
&lt;li&gt;Venues, organizers and categories are taxonomies:

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mec_location&lt;/code&gt;: &lt;code&gt;address&lt;/code&gt;, &lt;code&gt;latitude&lt;/code&gt; and &lt;code&gt;longitude&lt;/code&gt; in term meta.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mec_organizer&lt;/code&gt;: &lt;code&gt;tel&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt; and &lt;code&gt;url&lt;/code&gt; in term meta.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mec_category&lt;/code&gt;: the colour is in &lt;code&gt;mec_cat_color&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Bookings, if you used them, are separate: &lt;code&gt;mec-books&lt;/code&gt; posts plus a &lt;code&gt;wp_mec_bookings&lt;/code&gt; table.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To see which repeat types you actually have (and so which of the traps below apply to you):&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="n"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meta_value&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;repeat_type&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;events&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wp_postmeta&lt;/span&gt; &lt;span class="n"&gt;pm&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;wp_posts&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meta_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'mec_repeat_type'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'mec-events'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;post_status&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'trash'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'auto-draft'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;pm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;meta_value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty &lt;code&gt;repeat_type&lt;/code&gt; means the event doesn't repeat.&lt;/p&gt;

&lt;h2&gt;
  
  
  The traps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Weekly intervals are stored in days.&lt;/strong&gt; "Every 2 weeks" is saved as &lt;code&gt;mec_repeat_interval = 14&lt;/code&gt;. MEC multiplies the week count by 7 before saving. Copy it straight into an RRULE and you get &lt;code&gt;INTERVAL=14&lt;/code&gt;, which is every fourteen weeks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. "Ends after N times" is stored as N − 1.&lt;/strong&gt; &lt;code&gt;mec_repeat_end_at_occurrences&lt;/code&gt; holds one less than what the user typed. Six classes becomes 5 in the database.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$type&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mec_repeat_type'&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="nv"&gt;$interval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;get_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mec_repeat_interval'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'weekly'&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$type&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$interval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&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="nb"&gt;intdiv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$interval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&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="nv"&gt;$count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'occurrences'&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nf"&gt;get_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mec_repeat_end'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;get_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mec_repeat_end_at_occurrences'&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="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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Weekday numbers are ISO.&lt;/strong&gt; &lt;code&gt;mec_certain_weekdays&lt;/code&gt; uses 1 = Monday through 7 = Sunday, not the 0 = Sunday you'd get from PHP's &lt;code&gt;w&lt;/code&gt; or JavaScript. The "weekday" and "weekend" repeat types don't store their days on the event at all. They come from MEC's settings (&lt;code&gt;mec_options&lt;/code&gt; → &lt;code&gt;settings&lt;/code&gt; → &lt;code&gt;weekdays&lt;/code&gt; / &lt;code&gt;weekends&lt;/code&gt;), which default to 1–5 and 6–7.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. "Advanced" repeats are little tokens.&lt;/strong&gt; &lt;code&gt;mec_advanced_days&lt;/code&gt; holds values like &lt;code&gt;Sun.1&lt;/code&gt; (first Sunday of the month) or &lt;code&gt;Fri.l&lt;/code&gt; (last Friday). That's a monthly rule by position, &lt;code&gt;BYDAY=1SU&lt;/code&gt; or &lt;code&gt;BYDAY=-1FR&lt;/code&gt;. One event can hold several tokens, so check for that before you assume one rule per event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Location or organizer ID 1 means "hide it".&lt;/strong&gt; It isn't a venue. If you import it as one you'll end up with a venue called whatever term 1 is on your site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Times are stored three ways.&lt;/strong&gt; There are 12-hour fields (&lt;code&gt;mec_start_time_hour&lt;/code&gt;, &lt;code&gt;_minutes&lt;/code&gt;, &lt;code&gt;_ampm&lt;/code&gt;), a seconds-after-midnight field, and a datetime string. On sites set to 24-hour time MEC converts the hour back to 12-hour before saving it. Use &lt;code&gt;mec_start_day_seconds&lt;/code&gt; / &lt;code&gt;mec_end_day_seconds&lt;/code&gt; and skip the hour fields.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Extra and skipped days.&lt;/strong&gt; Skipped dates are a comma list in &lt;code&gt;mec_not_in_days&lt;/code&gt;. The "custom days" repeat type keeps its own list in &lt;code&gt;mec_in_days&lt;/code&gt; as &lt;code&gt;start:end&lt;/code&gt; pairs (&lt;code&gt;2026-10-17:2026-10-17&lt;/code&gt;), sometimes with times appended as &lt;code&gt;hh-mm-AM&lt;/code&gt;. There's no rule behind those, just the list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Your URLs may not be the defaults.&lt;/strong&gt; Single events live at &lt;code&gt;/events/&amp;lt;slug&amp;gt;/&lt;/code&gt; and categories at &lt;code&gt;/mec-category/&amp;lt;slug&amp;gt;/&lt;/code&gt; unless someone changed them under MEC's settings (&lt;code&gt;slug&lt;/code&gt; and &lt;code&gt;category_slug&lt;/code&gt; in the same &lt;code&gt;mec_options&lt;/code&gt; array). Read those two values before you switch, or your redirects will only cover URLs your site never used.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you switch
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Run the query above and note which repeat types you have.&lt;/li&gt;
&lt;li&gt;Spot-check one weekly event and one "ends after N times" event by hand against MEC's own calendar.&lt;/li&gt;
&lt;li&gt;Write down your two URL slugs.&lt;/li&gt;
&lt;li&gt;Decide what happens to bookings. They're in their own post type and table, not on the events.&lt;/li&gt;
&lt;li&gt;Keep MEC installed but deactivated until you've compared a month of dates side by side.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Disclosure
&lt;/h2&gt;

&lt;p&gt;I build Beacon Events, a free events calendar on wordpress.org, and version 0.10.1 has an importer for exactly this: &lt;a href="https://wordpress.org/plugins/beacon-events/" rel="noopener noreferrer"&gt;https://wordpress.org/plugins/beacon-events/&lt;/a&gt; . It handles the traps above, keeps venues with their addresses, organizers, category colours, images and timezones, and redirects old event and category links, custom slugs included, once MEC is off. What it doesn't do: a "custom days" list comes in as its first date, an advanced rule with several weekdays keeps the first one, and MEC bookings aren't imported. The report after the import lists the events that hit one of those.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>mysql</category>
      <category>webdev</category>
    </item>
    <item>
      <title>QR ticket check-in for a WordPress event, without a scanner app</title>
      <dc:creator>Jeffrey Inman</dc:creator>
      <pubDate>Mon, 28 Sep 2026 21:00:05 +0000</pubDate>
      <link>https://dev.to/jeffreyinman/qr-ticket-check-in-for-a-wordpress-event-without-a-scanner-app-bn5</link>
      <guid>https://dev.to/jeffreyinman/qr-ticket-check-in-for-a-wordpress-event-without-a-scanner-app-bn5</guid>
      <description>&lt;p&gt;Most small events check people in off a printed list. It works until the line gets long and two volunteers are flipping through the same three pages looking for "Maria". The usual upgrade is a scanner app, which means everyone at the door installs something and logs into it.&lt;/p&gt;

&lt;p&gt;There's a simpler version. Every phone camera already reads QR codes and opens whatever URL is inside. So the QR on a ticket can just be a link to your own site, and "checking someone in" is that page loading on a volunteer's phone.&lt;/p&gt;

&lt;p&gt;I built this for a WordPress events plugin this week and a few of the decisions weren't obvious, so here they are. The snippets are plain PHP against WordPress APIs; adapt the table and names to whatever stores your bookings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't put the booking link in the QR
&lt;/h2&gt;

&lt;p&gt;Most booking systems email a private link that lets the guest see or cancel their booking. It's tempting to reuse it in the QR. Don't: people post photos of their tickets, and anyone who scans that photo gets the guest's cancel link.&lt;/p&gt;

&lt;p&gt;Put a separate code in the QR that can only do one thing, check in. I derive it from the booking id and the booking's secret token with an HMAC, so it can't be guessed and doesn't reveal the token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;checkin_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$booking&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$mac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'sha256'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'checkin|'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$booking&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'|'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$booking&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'token'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nf"&gt;wp_salt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'auth'&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="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$booking&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$mac&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="mi"&gt;24&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 96 bits is plenty for this&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;booking_by_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt; &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;?array&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="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/^(\d+)-([a-f0-9]{24})$/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$m&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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_booking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$m&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="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// your lookup&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;checkin_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$row&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hash_equals&lt;/code&gt; rather than &lt;code&gt;===&lt;/code&gt; so the comparison takes the same time whether the first character or the last one is wrong. The &lt;code&gt;checkin|&lt;/code&gt; prefix keeps this MAC separate from anything else you sign with the same salt.&lt;/p&gt;

&lt;p&gt;The QR then holds &lt;code&gt;https://example.com/?checkin=42-3f9c…&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Only staff phones can check people in
&lt;/h2&gt;

&lt;p&gt;The check-in URL does nothing for a logged-out visitor except send them to the login page and back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&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="nf"&gt;is_user_logged_in&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;wp_safe_redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;wp_login_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$checkin_url&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;exit&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="nf"&gt;current_user_can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'edit_others_posts'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// "This account cannot check people in."&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each volunteer logs in once on their phone before doors open. After that, scan, see the result, next person.&lt;/p&gt;

&lt;p&gt;A side effect I like: link previewers, email security scanners and chat apps that fetch URLs do it without your cookies, so a ticket pasted into Slack or forwarded to someone can't check anyone in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Yes, a GET request changes state
&lt;/h2&gt;

&lt;p&gt;Normally you don't let a GET change anything. Here the phone camera opens the link, so a GET is what you get, and putting a "Confirm" button on the page costs a tap per guest at the busiest moment of the night.&lt;/p&gt;

&lt;p&gt;I kept the GET and leaned on two things: the capability check above, and the fact that check-in is a one-way stamp. Loading the page twice doesn't do anything the first load didn't. If your check-in does more (charges something, issues a wristband number), use a POST with a nonce and accept the extra tap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two phones, one ticket
&lt;/h2&gt;

&lt;p&gt;This is the bug I actually shipped. My first version did the obvious thing: read the booking, see &lt;code&gt;checked_in&lt;/code&gt; is empty, write the time. If two volunteers scan the same ticket at the same moment (a couple sharing one ticket, say), both requests read "not checked in" and both phones go green.&lt;/p&gt;

&lt;p&gt;The fix is to make the write itself the check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$wpdb&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'UPDATE %i SET checked_in = %s WHERE id = %d AND checked_in IS NULL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;gmdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'Y-m-d H:i:s'&lt;/span&gt; &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$booking&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'id'&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="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$n&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'ok'&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'already'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database only lets one of those updates match the row, so exactly one phone gets "ok". The other re-reads the row and shows "Already checked in at 7:42 pm", which is the information the volunteer actually needs. (&lt;code&gt;%i&lt;/code&gt; for identifiers needs WordPress 6.2+.)&lt;/p&gt;

&lt;p&gt;I wrote a test that loads the same row twice and checks it in with both copies. It failed on the old code and passes on the new one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Screens you can read at arm's length
&lt;/h2&gt;

&lt;p&gt;Doors are dark and people are impatient, so the result page is one word on a colored background:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;green "Checked in", with the name, party size and ticket type&lt;/li&gt;
&lt;li&gt;amber "Already checked in at 7:42 pm"&lt;/li&gt;
&lt;li&gt;red for cancelled, unpaid, waitlisted, or a code that doesn't match anything&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Send &lt;code&gt;noindex&lt;/code&gt; and no-cache headers on both the ticket page and the check-in page. Neither should ever end up in a search index or a shared cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  Draw the QR in the browser
&lt;/h2&gt;

&lt;p&gt;Don't send the ticket URL to a third-party "QR image" API to get a PNG back. That URL is a credential, and you'd be handing it to someone else's logs. A small MIT library like &lt;a href="https://github.com/kazuhikoarase/qrcode-generator" rel="noopener noreferrer"&gt;qrcode-generator&lt;/a&gt; draws it client-side as an SVG:&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;var&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;qrcode&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="s1"&gt;M&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// auto size, medium error correction&lt;/span&gt;
&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;checkinUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;make&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;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ticket-qr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createSvgTag&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;cellSize&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="na"&gt;margin&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="na"&gt;scalable&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SVG stays sharp at any screen size. I checked the output by decoding a screenshot of the ticket page with jsQR; it came back as the exact check-in URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't handle
&lt;/h2&gt;

&lt;p&gt;It needs a data connection at the door. If your venue is a basement with no signal, keep a printed door list as the fallback, with a box to tick per person. And it's one check-in per booking: a booking for four people is one scan, and the screen says "× 4" so the volunteer knows to wave the whole group through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclosure
&lt;/h2&gt;

&lt;p&gt;I built this for Beacon Events Pro, the paid add-on to &lt;a href="https://wordpress.org/plugins/beacon-events/" rel="noopener noreferrer"&gt;Beacon Events&lt;/a&gt;, a free events calendar on wordpress.org. The QR tickets, check-in screens and door list are in the add-on. Everything above works the same in your own code if you'd rather build it yourself.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What WooCommerce Bookings really costs a small rental shop, and the free options</title>
      <dc:creator>Jeffrey Inman</dc:creator>
      <pubDate>Mon, 28 Sep 2026 15:20:00 +0000</pubDate>
      <link>https://dev.to/jeffreyinman/what-woocommerce-bookings-really-costs-a-small-rental-shop-and-the-free-options-17od</link>
      <guid>https://dev.to/jeffreyinman/what-woocommerce-bookings-really-costs-a-small-rental-shop-and-the-free-options-17od</guid>
      <description>&lt;p&gt;If you rent kayaks, cabins or bikes, or run tours or classes, and your shop is on WooCommerce, the default answer for taking bookings is the official WooCommerce Bookings extension. Here's what that costs a small shop over a year, what you actually need, and the free options.&lt;/p&gt;

&lt;p&gt;Disclosure: I built Beacon Bookings, a free WooCommerce bookings plugin with a paid add-on. The prices below come from the vendors' own pages, so check them yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the official route costs
&lt;/h2&gt;

&lt;p&gt;From woocommerce.com (September 2026):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WooCommerce Bookings: $249 a year.&lt;/li&gt;
&lt;li&gt;Deposits aren't included. A reviewer on that page notes you pay "an extra $182 for the WooCommerce deposits plugin".&lt;/li&gt;
&lt;li&gt;"WooCommerce Bookings Availability", the add-on that shows customers a calendar of what's free, is $59.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's $490 a year if you want deposits and a customer-facing calendar. The Bookings extension is rated 2.7 stars on its own store page, and half of its 60 reviews are one or two stars.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a rental or tour shop actually needs
&lt;/h2&gt;

&lt;p&gt;Most small shops need the same short list:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Units that match the business.&lt;/strong&gt; Hours for kayaks, nights for cabins, a fixed 2-hour slot for a tour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capacity.&lt;/strong&gt; You have 6 kayaks, not 1. A tour takes 12 people, not 12 separate bookings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;People counts.&lt;/strong&gt; "4 people" should use 4 seats, and sometimes multiply the price.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Buffers.&lt;/strong&gt; Cleaning time between cabin stays, drive time between lessons.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No double bookings.&lt;/strong&gt; The slot is checked when the customer picks it, held while it's in the cart, and checked again at checkout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own calendar.&lt;/strong&gt; An .ics feed you can subscribe to from Google or Apple Calendar is enough for most people.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deposits&lt;/strong&gt;, if you take them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Numbers 1 to 6 are the core. Only 7 is really "extra".&lt;/p&gt;

&lt;h2&gt;
  
  
  The free options
&lt;/h2&gt;

&lt;p&gt;On wordpress.org there are good free booking plugins, but most are appointment schedulers (LatePoint, Simply Schedule Appointments, Amelia's free tier). They're built around staff and time slots and run their own checkout. If you want bookings to be WooCommerce products, sold through the normal cart next to your other products, the list is short:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Easy Booking&lt;/strong&gt;: date-range bookings for WooCommerce. Good for simple rentals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Booking Activities&lt;/strong&gt;: strong for classes and events with fixed sessions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Beacon Bookings&lt;/strong&gt; (mine): a bookable product type with minutes to nights, capacity per slot or per resource (rooms, staff, boats), people counts, buffers, cart holds with a locked re-check at checkout, emails with calendar files, calendar feeds, and a calendar block. Deposits, seasonal pricing and Google Calendar sync are in a paid add-on.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to choose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Fixed class times with a seat limit: try Booking Activities first.&lt;/li&gt;
&lt;li&gt;Simple "from date, to date" rentals: Easy Booking is enough.&lt;/li&gt;
&lt;li&gt;Mixed inventory (6 kayaks and 2 guides, cabins with cleaning days, people-based pricing): you need capacity, resources and buffers together. That's what I built Beacon Bookings for.&lt;/li&gt;
&lt;li&gt;Already paying for WooCommerce Bookings and it works for you: there's no reason to move.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whatever you pick, test one thing before going live. Open two browsers, put the last slot in both carts, and check out in both. Only one should succeed.&lt;/p&gt;

&lt;p&gt;Beacon Bookings on wordpress.org: &lt;a href="https://wordpress.org/plugins/beacon-bookings/" rel="noopener noreferrer"&gt;https://wordpress.org/plugins/beacon-bookings/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>woocommerce</category>
      <category>ecommerce</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A church calendar on WordPress that volunteers can keep up</title>
      <dc:creator>Jeffrey Inman</dc:creator>
      <pubDate>Mon, 28 Sep 2026 00:55:16 +0000</pubDate>
      <link>https://dev.to/jeffreyinman/a-church-calendar-on-wordpress-that-volunteers-can-keep-up-492g</link>
      <guid>https://dev.to/jeffreyinman/a-church-calendar-on-wordpress-that-volunteers-can-keep-up-492g</guid>
      <description>&lt;p&gt;Most church websites need the same calendar: a weekly service, a few monthly groups, special services around the holidays, and the occasional potluck or retreat people sign up for. Here's how to set that up in WordPress so volunteers can keep it current, and what to check in whatever plugin you use.&lt;/p&gt;

&lt;p&gt;Disclosure: I build Beacon Events, a WordPress events plugin. The setup below works in most calendar plugins; where it depends on one, I say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enter the rhythm once
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Weekly service as one repeating event.&lt;/strong&gt; Sunday 10am, every week, no end date. You should never have to copy it forward by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monthly groups by position, not date.&lt;/strong&gt; "Second Tuesday" for the women's group, "last Friday" for youth night. Check that your plugin can repeat by weekday position; some only repeat on the same date each month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Special services as single events.&lt;/strong&gt; Christmas Eve, Good Friday and Easter each get their own event, so the weekly service doesn't have to bend around them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Handle the week plans change
&lt;/h2&gt;

&lt;p&gt;The service that moves to the park for a picnic, the Wednesday study cancelled for Thanksgiving: you want to change one date without breaking the series. Look for a way to cancel or move a single occurrence that shows visitors "cancelled" or the new time, rather than deleting the whole event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let members take it with them
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A subscribe link.&lt;/strong&gt; Most calendar plugins publish an iCal feed. Put the link on the page so members can add the whole calendar to their phone, and changes show up there on their own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Colours per ministry.&lt;/strong&gt; Worship, youth, outreach: a colour per category makes a month view readable at a glance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The address and a map link on every event,&lt;/strong&gt; and a join link for anything you livestream.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If your office already lives in Google Calendar
&lt;/h2&gt;

&lt;p&gt;Many church offices keep the master calendar in Google Calendar. Instead of embedding Google's iframe, which doesn't match the site and doesn't resize well on phones, sync the calendar's feed into your events plugin so each event becomes a real page on your site. I wrote up what to check for in that sync &lt;a href="https://dev.to/jeffreyinman/show-a-google-calendar-on-wordpress-without-the-iframe-5c3p"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sign-ups
&lt;/h2&gt;

&lt;p&gt;For potlucks, retreats and classes where you need a headcount, look for RSVPs with a capacity and a waitlist, so you're not collecting names in a spreadsheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to try Beacon Events
&lt;/h2&gt;

&lt;p&gt;Beacon Events is free on wordpress.org. It covers the rhythm above without paid add-ons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;repeating events, including "second Tuesday" and "last Friday";&lt;/li&gt;
&lt;li&gt;cancelling or moving one date, which then shows as cancelled or rescheduled;&lt;/li&gt;
&lt;li&gt;a subscribe link, category colours, and addresses with a map link;&lt;/li&gt;
&lt;li&gt;online events with a join link;&lt;/li&gt;
&lt;li&gt;Google Calendar sync with a log of each run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;RSVPs with a capacity, a waitlist and reminder emails are in the paid add-on, which has a 14-day trial with no card.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/_X5LTwl0Y3I" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://wordpress.org/plugins/beacon-events/" rel="noopener noreferrer"&gt;https://wordpress.org/plugins/beacon-events/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If your church calendar has a pattern this doesn't cover, post it in the comments.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>beginners</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Moving off Events Manager: what's in your database and what breaks</title>
      <dc:creator>Jeffrey Inman</dc:creator>
      <pubDate>Sun, 27 Sep 2026 23:55:27 +0000</pubDate>
      <link>https://dev.to/jeffreyinman/moving-off-events-manager-whats-in-your-database-and-what-breaks-1gfi</link>
      <guid>https://dev.to/jeffreyinman/moving-off-events-manager-whats-in-your-database-and-what-breaks-1gfi</guid>
      <description>&lt;p&gt;Events Manager is one of the oldest event plugins on WordPress, and it stores events differently from most. If you're moving off it, here's what's in your database, where migrations usually break, and a checklist to run before you switch.&lt;/p&gt;

&lt;p&gt;Disclosure: I build Beacon Events, a WordPress events plugin with an Events Manager importer. The details below apply whatever you move to.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Events Manager stores events
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Events live in their own table, not just in posts.&lt;/strong&gt; Each event is a post of type &lt;code&gt;event&lt;/code&gt; plus a row in &lt;code&gt;wp_em_events&lt;/code&gt; with the dates, times, timezone and a link to its location. The row holds the real schedule, so read the table rather than the post meta.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Locations are separate records&lt;/strong&gt; in &lt;code&gt;wp_em_locations&lt;/code&gt;, with the address split into fields (address, town, state, postcode, country).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A repeating event is a template plus copies.&lt;/strong&gt; Events Manager keeps one "repeating" event that holds the rule, then creates a separate event for every date it produces. Depending on the version, the rule sits on the template itself (older versions) or in its own recurrences table (newer ones), where it can also include or exclude sets of dates.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where migrations go wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every date becomes its own event.&lt;/strong&gt; Importing the generated copies turns a weekly class into 52 unrelated events. Take the template and its rule instead, and skip the copies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Excluded dates come back.&lt;/strong&gt; When a repeating event has dates excluded, those need to arrive as skipped dates on the new rule, or the cancelled week reappears.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Specific dates" events don't fit a rule.&lt;/strong&gt; Events Manager can repeat on a hand-picked list of dates. Most rule-based calendars can't express that, so check what your importer does with them. The honest answer is usually to keep the first date and tell you about the rest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Old links break.&lt;/strong&gt; Events Manager leaves placeholder pages called "events" and "locations" behind, and your old event and location URLs point at them. Without redirects, search traffic and shared links go to empty pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bookings are a separate job.&lt;/strong&gt; Events Manager has its own bookings system. Moving the events doesn't move the people who booked them, so export your booking lists first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A checklist that holds up
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Back up the database, including the &lt;code&gt;wp_em_&lt;/code&gt; tables.&lt;/li&gt;
&lt;li&gt;Count your published events, and note which ones repeat.&lt;/li&gt;
&lt;li&gt;Export your current bookings from Events Manager and keep the file.&lt;/li&gt;
&lt;li&gt;Run the import with Events Manager still active, then check one repeating event with an excluded date and one event on specific dates.&lt;/li&gt;
&lt;li&gt;Click a few old event and location links after you deactivate Events Manager.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  If you want to try Beacon Events
&lt;/h2&gt;

&lt;p&gt;Beacon Events is free on wordpress.org, and its Events Manager importer does the above. Each repeating event comes across as one event with its rule and excluded dates. Locations become venues with their full address, and categories keep their nesting. Old &lt;code&gt;/events/…&lt;/code&gt; and &lt;code&gt;/locations/…&lt;/code&gt; links redirect once Events Manager is switched off. "Specific dates" events keep their first date and are listed in the import report, and Events Manager bookings aren't imported. Recurring events, Google Calendar sync and month, list and day views are free. RSVPs and tickets are in a paid add-on.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/_X5LTwl0Y3I" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://wordpress.org/plugins/beacon-events/" rel="noopener noreferrer"&gt;https://wordpress.org/plugins/beacon-events/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If your Events Manager setup has something this misses, say so in the comments and I'll look at it.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>webdev</category>
      <category>php</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Show a Google Calendar on WordPress without the iframe</title>
      <dc:creator>Jeffrey Inman</dc:creator>
      <pubDate>Sun, 27 Sep 2026 22:01:58 +0000</pubDate>
      <link>https://dev.to/jeffreyinman/show-a-google-calendar-on-wordpress-without-the-iframe-5c3p</link>
      <guid>https://dev.to/jeffreyinman/show-a-google-calendar-on-wordpress-without-the-iframe-5c3p</guid>
      <description>&lt;p&gt;A lot of club, church and studio websites show their events with Google Calendar's embed code. It works, and it's free, but it's also why the events page looks like a different website. Here's what the embed gives you, the other way to put a Google Calendar on a WordPress site, and what to check before you trust a plugin with it.&lt;/p&gt;

&lt;p&gt;Disclosure: I build Beacon Events, a WordPress events plugin that syncs calendar feeds. The checklist below applies to whatever you use.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the embed gives you
&lt;/h2&gt;

&lt;p&gt;Google's embed code is an iframe with a fixed width and height (its customize page asks for at least 500 by 500). Inside it, everything is Google's page:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't fit.&lt;/strong&gt; A fixed-size box needs extra CSS before it behaves on a phone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't match.&lt;/strong&gt; You can pick colours and hide a few tabs, but not your theme's fonts or layout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your site gets nothing for search.&lt;/strong&gt; The events live on Google's page, so your site has no page per event and no Event markup for search results.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The other way: sync the feed
&lt;/h2&gt;

&lt;p&gt;Every Google Calendar has an iCal (ICS) address. A plugin can read that address on a schedule and turn each event into a real post on your site. You keep editing in Google Calendar, as you do now, and the website follows.&lt;/p&gt;

&lt;p&gt;To get the address: open Google Calendar on a computer, go to Settings, click your calendar on the left, and click "Integrate calendar". The "Public address in iCal format" only works if the calendar is shared publicly ("Make available to public" under access permissions). If you'd rather not make it public, the "Secret address in iCal format" works too. Treat it like a password: anyone who has it can read the whole calendar.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check in a plugin that syncs
&lt;/h2&gt;

&lt;p&gt;Google's feed has a few habits that trip up sync code. Try these with a test calendar before you point a plugin at the real one.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A repeating event stays one event.&lt;/strong&gt; A weekly service should arrive as one event with a rule, not 52 separate posts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One changed date doesn't break the series.&lt;/strong&gt; When you move or cancel a single date of a repeating event, Google sends that date as a separate entry with the same ID as the series. Sync code that matches on the ID alone overwrites the whole series with that one date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Old series still show upcoming dates.&lt;/strong&gt; A weekly event that started years ago should still show next week. Some plugins only work out dates for a fixed period from the first one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skipped dates all stay skipped.&lt;/strong&gt; Google writes one line per skipped date. Code that keeps only the last one brings the others back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evening events land on the right day.&lt;/strong&gt; Some feeds write times in UTC, where 7:30 pm in Chicago is already Wednesday. The weekday has to be moved back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleted events are handled in a way you can live with.&lt;/strong&gt; Removed from the site, hidden, or kept: know which before it happens to a real event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You know how often it syncs.&lt;/strong&gt; WordPress's scheduled tasks run when someone visits the site, so an "hourly" sync on a quiet site can run late unless the host runs a real cron job.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One more thing that's true of any sync: edits you make on the WordPress side get overwritten on the next run, because the feed is the source. Change events in Google Calendar, not in WordPress.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to try Beacon Events
&lt;/h2&gt;

&lt;p&gt;The calendar is free on wordpress.org. Paste the feed address under Events → Settings, optionally followed by a category, and it syncs hourly with a log of what was created, updated or removed each time. A repeating event stays one event, and a moved or cancelled date shows as moved or cancelled on the series. Events that disappear from the feed become drafts rather than being deleted. It shows month, list and day views as blocks or shortcodes in your theme's fonts, and every event gets its own page with Event markup for search.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/_X5LTwl0Y3I" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://wordpress.org/plugins/beacon-events/" rel="noopener noreferrer"&gt;https://wordpress.org/plugins/beacon-events/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If your calendar does something odd in any plugin, describe it in the comments and I'll look at the feed.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Moving off Event Tickets Plus: what your WooCommerce ticket data looks like</title>
      <dc:creator>Jeffrey Inman</dc:creator>
      <pubDate>Sun, 27 Sep 2026 19:22:35 +0000</pubDate>
      <link>https://dev.to/jeffreyinman/moving-off-event-tickets-plus-what-your-woocommerce-ticket-data-looks-like-2akm</link>
      <guid>https://dev.to/jeffreyinman/moving-off-event-tickets-plus-what-your-woocommerce-ticket-data-looks-like-2akm</guid>
      <description>&lt;p&gt;Event Tickets Plus sells your event tickets through WooCommerce. If you're moving off it, whether because of the Liquid Web changes or because you're leaving The Events Calendar altogether, the tickets and attendees are the part that's easy to get wrong. Here's what that data looks like in your database and where a migration usually breaks.&lt;/p&gt;

&lt;p&gt;Disclosure: I build Beacon Events, a WordPress events plugin, and I wrote an importer for this data this week. The details below apply whatever you move to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in your database
&lt;/h2&gt;

&lt;p&gt;The field names below come from the free Event Tickets plugin's own code, which maps every ticket provider it supports.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Each ticket is a WooCommerce product.&lt;/strong&gt; It carries &lt;code&gt;_tribe_wooticket_for_event&lt;/code&gt; with the event's post ID, and &lt;code&gt;_tribe_ticket_capacity&lt;/code&gt; with the ticket's capacity (-1 means unlimited). The price is the product's price.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each attendee is a &lt;code&gt;tribe_wooticket&lt;/code&gt; post&lt;/strong&gt;, one per ticket sold, so an order for three tickets makes three attendees. It points back with &lt;code&gt;_tribe_wooticket_event&lt;/code&gt; (the event), &lt;code&gt;_tribe_wooticket_product&lt;/code&gt; (which ticket), &lt;code&gt;_tribe_wooticket_order&lt;/code&gt; (the WooCommerce order) and &lt;code&gt;_tribe_wooticket_order_item&lt;/code&gt; (the line in that order). &lt;code&gt;_paid_price&lt;/code&gt; holds what was paid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether someone actually paid lives in WooCommerce,&lt;/strong&gt; in the order's status, not in the attendee record.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where migrations go wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unpaid orders become attendees.&lt;/strong&gt; Orders that are pending or on hold still have attendee posts. Only completed and processing orders are paid, so leave the rest out, and drop refunded, cancelled and failed ones too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Newer stores keep orders out of &lt;code&gt;wp_posts&lt;/code&gt;.&lt;/strong&gt; With WooCommerce's High-Performance Order Storage (HPOS), orders live in their own &lt;code&gt;wc_orders&lt;/code&gt; table. A query that joins attendees to &lt;code&gt;shop_order&lt;/code&gt; posts finds nothing on those sites. Ask WooCommerce for the order (&lt;code&gt;wc_get_order()&lt;/code&gt;) instead, which works either way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The buyer isn't always the guest.&lt;/strong&gt; One person can buy tickets for three people. Use the name and email stored on each attendee first, and fall back to the order's billing details only when those are empty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ticket types collapse into one price.&lt;/strong&gt; An event with early bird, general and VIP tickets is three products. If your new plugin only has one price per event, you lose that, and your existing attendees lose the record of what they bought.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The import sends emails.&lt;/strong&gt; A migration shouldn't send everyone a fresh confirmation. Check that the importer is silent before you run it on real data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A checklist that holds up
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Back up the database, including the WooCommerce order tables.&lt;/li&gt;
&lt;li&gt;Count your paid attendees in WooCommerce before you start, so you have a number to compare against.&lt;/li&gt;
&lt;li&gt;Run the import with Event Tickets Plus still active, and check one event with several ticket types and one order that was refunded.&lt;/li&gt;
&lt;li&gt;Make sure capacity still counts the people you imported, so nobody can buy a seat that's already taken.&lt;/li&gt;
&lt;li&gt;Only then switch the old plugins off.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  If you want to try Beacon Events
&lt;/h2&gt;

&lt;p&gt;The calendar is free on wordpress.org. Its paid add-on, Beacon Events Pro, imports Event Tickets Plus data when you move from The Events Calendar. That covers each ticket as its own ticket type with its price and capacity, and every attendee of a completed or processing order under their own name, with the WooCommerce order linked. Unpaid orders are listed in the report, and no emails go out. New tickets then sell through your WooCommerce checkout or your own Stripe account with no per-ticket fee from us. It also has check-in at the door and a printable door list. It's $49 a year for one site, with a 14-day free trial that needs no card.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/_4tAJjVqA-Y" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;&lt;a href="https://wordpress.org/plugins/beacon-events/" rel="noopener noreferrer"&gt;https://wordpress.org/plugins/beacon-events/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If your Tickets Plus setup has something this doesn't cover, say so in the comments and I'll look at it.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>woocommerce</category>
      <category>php</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
