WordPress 7.0 “Armstrong” Is Live — Post-Release Deep Dive 🎺

AI Abilities surfaced in the WordPress 7.0 editor

It’s out. After a delayed launch, a last-minute feature removal, and weeks of “will it / won’t it” energy from the community — WordPress 7.0 “Armstrong” officially went live on May 20, 2026.

I covered the pre-release picture in detail earlier in this article — WordPress 7.0 — Let’s Dive!. Now that the build is actually in people’s hands, this is the post-release breakdown — what shipped, what it feels like in practice, what broke, and what you should do before touching the update button on a production site.

🎺 Why “Armstrong”?

Every WordPress major release gets named after a jazz musician. This time it’s Louis “Satchmo” Armstrong — a fitting pick for a release built around giving individual builders more expressive power and their own set of tools to work with.

Armstrong didn’t just play jazz — he rewired how the whole genre thought about itself. He took something collaborative and orchestral and made space for individual voice and improvisation to lead. There’s a parallel to what WordPress 7.0 is trying to do architecturally: instead of AI being a monolithic, vendor-locked thing bolted on, it becomes a platform where you bring your own model, your own workflow, your own expression.

Also worth flagging: this is the 30th major WordPress release since 1.0 “Miles” shipped in January 2004. More than 900 contributors were involved, with close to 280 of them contributing to a WordPress release for the first time. That’s a meaningful number.

🤖 AI in Core — The Real Headline

Let me frame this honestly before we get into the details: WordPress 7.0 doesn’t make your site “do AI” automatically. If you update to 7.0 and don’t configure anything, your site will feel identical to 6.9. No AI widget, no content generator, nothing.

What 7.0 does is lay down the infrastructure layer — the wiring — so that AI becomes a consistent, stable, vendor-neutral part of how WordPress works going forward. Think back to when the REST API landed. The power wasn’t in day one; it was in what the ecosystem built on top of it over the next few years. This is that moment for AI.

Here’s the architecture:

WP AI Client

A new communication layer built into Core that any plugin or theme can call to send prompts to an external AI model. The calling code doesn’t need to know which model is on the other end — it just talks to the Client, and the Client routes it. This eliminates the pattern of every AI plugin bundling its own SDK and managing its own API keys.

Abilities API (PHP)

A contract system for AI-powered capabilities. A plugin registers what it can do — summarise content, generate an alt text suggestion, rewrite a paragraph — in a way that both humans navigating the UI and machine agents can discover and invoke. This is the piece that makes 7.0 feel agentic rather than just AI-adjacent.

Client-Side Abilities (JavaScript)

The same Abilities concept, now accessible in the browser. There’s a built-in UI component and a command palette hook, meaning abilities surface in the editor itself — not buried in settings pages. Developers building editor-integrated AI features will appreciate this a lot.

The AI Plugin (Separate & Optional)

The AI Client on its own generates nothing. To get actual output — image generation, title suggestions, excerpt drafts, alt text — you install the separately distributed AI plugin. It sits on top of the Client and Abilities API and activates the generation features. Purely opt-in.

AI Abilities surfaced in the WordPress 7.0 editor
AI Abilities API surfaced inside the editor — tools appear where you’re actually working.

🔌 Connectors — Quick Overview

The Settings → Connectors screen is the user-facing piece of the AI infrastructure, and it solves a genuinely frustrating problem: credential sprawl.

Before 7.0, if you ran multiple AI-powered plugins, you were probably entering the same API keys multiple times across multiple settings panels. Different plugins, same credentials, no coordination. It was messy. 7.0 fixes this at the platform level.

You enter your API key once per provider on the Connectors screen. Any plugin that speaks the AI Client API inherits that connection automatically — no extra configuration, no duplicate keys. Three providers ship ready to connect out of the box: OpenAI, Anthropic (Claude), and Google (Gemini). You’re not locked into any of them, and the architecture supports custom providers too.

The WordPress 7.0 Connectors screen
Settings → Connectors — one screen, one set of credentials, everything that speaks AI Client just works.

The vendor neutrality angle matters here. There’s no partnership deal pushing you toward one model. Your API keys stay yours. If the AI landscape shifts in two years (it will), you swap a provider connection — you don’t rebuild your plugin stack.

📌 Deeper dive incoming: Connectors is a topic that deserves its own full article — including a video walkthrough of setting up each provider, how to configure multi-provider setups, and which plugins are already building on top of this. Coming soon. Follow along so you don’t miss it.

🖥️ The Dashboard Refresh — Long Overdue

Spending a day in the new wp-admin and then opening 6.9 in another tab felt like time travel. The gap is more noticeable than I expected.

New visual foundation

Tighter spacing, a cleaner colour palette, smoother view transitions between screens. Nothing revolutionary in isolation, but together they make the admin feel like a product that was designed rather than accumulated. If you spend hours a day in the dashboard, this compounds.

⌘K / Ctrl+K — Command Palette

This is my personal favourite addition in the entire release. Hit the shortcut from anywhere in wp-admin and a fuzzy-search overlay opens. Type a post title, a settings screen name, a command — you’re there instantly. No sidebar, no menu hierarchy, no clicking through three levels. Once it’s in your muscle memory you’ll miss it in every other CMS you touch.

The WordPress 7.0 Command Palette
Hit ⌘K from anywhere in the dashboard — fuzzy search, instant navigation, zero clicking.

Font Library — All themes, one place

Managing fonts previously meant something different depending on whether you were in a block theme, a hybrid theme, or a classic theme. There was no single consistent answer. 7.0 gives you a dedicated Fonts page that works across all three — install a font, upload one, or remove ones you don’t need. All in one place, for the first time.

The WordPress 7.0 Font Library
One fonts page. Doesn’t care what kind of theme you’re running.

Visual Revisions — Actually Usable Now

Revisions in previous WordPress versions showed you a text-based diff. Red lines, green lines. Trying to figure out whether the layout had changed — not just the words — was nearly impossible. The new revisions interface shows visual markers along a scrubber. You can see what the page actually looked like at each save point and restore from there.

WordPress 7.0 Visual Revisions scrubber
Revision history you can actually navigate — scroll, see, restore.

🧱 Four New Blocks

Breadcrumbs

Genuinely surprising this took until 7.0, but here we are. A native Breadcrumbs block with theme.json support and compatibility across all block themes. Goodbye Yoast breadcrumb shortcodes and custom template functions for this specific use case.

Icons

A block that pulls from the @wordpress/icons library and lets you drop UI icons inline — inside callouts, feature lists, service grids. Saves you the “embed an SVG manually or install another plugin” decision on every new site.

Gallery — Lightbox Mode

The Gallery block now has a built-in lightbox and slideshow that activates on click. This was one of the most commonly plugin-dependent features in the editor. It’s native now.

Heading — Refined

Less visible, but if you build themes you’ll notice: cleaner markup output and more granular per-heading-level style control. Fewer overrides needed in your stylesheets.

🎨 Editor & Design Tools

Per-Block Custom CSS

You can now write CSS for a single block instance right inside the Advanced panel of the block sidebar. Not a global style, not a child theme rule — CSS scoped to that specific block on that specific page. It’s one of those features where the moment you use it you’ll wonder how it wasn’t there before.

Responsive Visibility Controls

Show or hide any block based on device size — mobile, tablet, desktop. Customise where the breakpoints sit. This has been handled by page builders and premium plugins for years; it’s now part of Core.

Block-Based Menu Overlay

The mobile nav overlay is a block canvas now. Add whatever you want — multiple columns, a branded header, custom close button, anything your design calls for. The days of the plain sliding menu being un-editable without custom code are done.

Patterns as Single Units

Patterns in the editor now behave as cohesive single objects rather than a loose collection of blocks that could accidentally get broken apart. You can still detach and edit individual elements, but the default is clean, predictable, and much less frustrating on complex layouts.

🛠️ Developer Changes Worth Your Attention

PHP-Only Block Registration

This is my favourite developer addition in the release. You can register a block entirely in PHP — no npm, no webpack, no block.json build step. The Block API picks it up automatically. The barrier for shipping a simple block just dropped significantly for PHP-first developers and plugin authors who’ve been avoiding the block editor because of the toolchain overhead.

Site Editor as an Extensible Platform

The Site Editor now has proper routing and route validation, plus a new wordpress/boot package that lets plugins register their own custom pages inside the Site Editor shell. It’s no longer a closed set of screens — plugins can build native-feeling experiences inside it. This is going to reshape how complex plugins approach their UX over the next year or two.

Interactivity API — watch() and data-wp-watch

Two additions that make reactive block development meaningfully cleaner. watch() lets you subscribe to state signal changes; data-wp-watch is the DOM directive counterpart. If you’ve built interactive blocks and ended up writing convoluted workarounds to respond to state without a full re-render, these exist specifically for that problem.

Block Bindings — More Control

The new block_bindings_supported_attributes filter lets you explicitly declare which attributes on your custom blocks are eligible for Pattern override. Cleaner, more intentional than the previous behaviour.

Scripts Can Declare Module Dependencies

The script loading system now lets traditional wp_register_script() scripts declare dependencies on ESM modules. It’s a small but important step in the ongoing migration toward native browser modules without breaking backward compatibility.

WP-CLI 3.0 Ships Alongside 7.0

Two new command groups: wp block for read-only block entity access, and wp ability for interacting with the AI Abilities API from the command line. If you’re building automations or running site audits, these are going to be useful early.

OPCache in Site Health

Tools → Site Health → Info → Server now shows your PHP OPCache status. One less reason to go digging through cPanel or hosting dashboards when diagnosing a performance issue.

Iframed Editor Auto-Detection

When every block on a page declares Block API v3, the editor automatically switches to iframed rendering. This resolves a persistent class of CSS bleed issues between admin styles and editor styles that developers have been working around for years. Old blocks on old API versions? No change — backward compatibility is maintained.

⚠️ Things That Will Break — Check Before You Update

What Action Required
PHP 7.2 / 7.3 Upgrade PHP before updating WordPress
add_theme_support( 'html5', array( 'script' ) ) Remove this — script loader handles it natively now
Author link title attributes Gone by default; use new $use_title_attr param where needed
Patterns in fully editable mode Default is now contentOnly — mark content attributes with "role": "content"
backbone.js, Requests, PHPMailer, CodeMirror All updated — verify any direct integrations

❌ The RTC Situation

If you read the pre-release article, you already know this — but for anyone coming in fresh:

Real-time collaborative editing did not make it into 7.0.

It was removed twelve days before launch. The reasons were technical and legitimate: concurrent editing sessions were causing race conditions in the sync layer, memory pressure wasn’t where it needed to be, and fuzz testing kept surfacing bugs that didn’t have clean fixes. Shipping it anyway would have been a mistake the scale of WordPress’s user base tends to punish harshly.

The collaborative foundation is still there — block-level Notes, the commenting system, the presence infrastructure. The multi-user simultaneous editing part moves to a feature plugin for extended community testing before attempting another Core merge. WordPress 7.1 (August 2026) is the next candidate window, but no firm commitment has been made yet.

I wrote about the full RTC removal in the pre-release update if you want the longer version.

🚀 How to Update Safely

For existing production sites: hold for one to two weeks. The plugin ecosystem needs time to release compatibility updates, and the combination of your specific theme, plugins, and hosting setup is never quite what the testing matrix covered.

For new sites you’re spinning up right now: skip 6.9 entirely. Start on 7.0.

Checklist before you touch anything:

  1. Take a full backup — files and database. Actually verify it downloaded.
  2. Confirm your PHP version — 7.4 minimum, 8.2 or 8.3 strongly recommended
  3. Update plugins and themes on a staging copy before touching Core
  4. Run the WordPress 7.0 update on staging and test your critical flows
  5. Push to production once staging is clean
    From the terminal:
# Update Core
wp core update --version=7.0

# Run the DB upgrade (important after major releases)
wp core update-db

# Flush rewrite rules
wp rewrite flush

💬 Where Does 7.0 Actually Land?

Stepping back from the feature list — what is WordPress 7.0 as a release?

It’s a foundations release. The AI layer, the Connectors architecture, the extensible Site Editor, PHP-only blocks — none of these are flashy “look what you can do today” features. They’re decisions about how WordPress should work for the next few years. And most of those decisions look right to me.

The AI infrastructure in particular is the kind of thing that will age well. Provider-agnostic, opt-in, built on a proper API surface. The kind of foundation that gets quietly more valuable as the ecosystem builds on top of it.

Losing RTC as the headline took the wind out of the launch narrative somewhat. But honestly, a rushed collaboration feature on 43% of the web would have been far more damaging than a delay. The core team made a hard call and made the right one.

The Command Palette is the thing you’ll feel every single day. The visual revisions scrubber is the thing your clients will finally understand. The AI Connectors screen is the thing that will matter most in twelve months.

Welcome to the Armstrong era. 🎺

📡 What’s Coming Next

This post opens a series of dedicated 7.0 deep-dives:

  • 🔌 Connectors Deep Dive + Video Walkthrough — setting up OpenAI, Claude, and Gemini; multi-provider patterns; real plugin integrations (coming very soon)
  • 🧱 PHP-Only Block Registration — Hands-On — building a real block without writing a line of JavaScript
  • ⚡ 7.0 Performance in the Real World — benchmarks across major hosting environments
  • 🔮 WordPress 7.1 Preview — what RTC might actually look like when it ships in August
    🔔 Follow me on dev.to to catch each one as it drops.

Research sources: WordPress.org Official Release, Make WordPress Core, WordPress Developer News, Make WordPress CLI

CD Projekt Red: “The Witcher 4 ve Cyberpunk 2’de Cyberpunk 2077’deki hataları tekrarlamayacağız.”

CD Projekt Red: "The Witcher 4 ve Cyberpunk 2'de Cyberpunk 2077'deki hataları tekrarlamayacağız."GamesRadar’a açıklamalarda bulunan CD Projekt Red’in üst düzey yöneticilerinden Jarosław Ruciński ve Adrian Fulneczek, çıkışı fiyaskoya dönüşen Cyberpunk 2077’deki hataları, aktif olarak geliştirme aşamasında olan The Witcher 4 ve Cyberpunk 2’de tekrarlamayacaklarını söyledi. “Dersimizi aldık” diyen yöneticiler, Cyberpunk 2077’deki teknik hataların tekrarlanmaması için yeni bir geliştirme sürecini devreye aldıklarını aktardı. Ruciński, geçmişin aksine bilgi birikiminin artık belirli ekiplere özel olmadığını ve öğrenilenlerin/yapılanların şirket içi ortak bir varlığa dönüştüğünü ifade ediyor. “Örneğin, The Witcher üzerinde çalışan bir ekip belirli bir sorun için çözüm ürettiğinde, Cyberpunk ekibi de bunu görebiliyor. Bu sayede diğer ekip geliştirilen çözümden yararlanabiliyor, kodu kendi projesine dahil edebiliyor ve üzerinde küçük değişiklikler yapabiliyor” diyen Ruciński, daha önce başlarına büyük sorun açan dokümantasyon sürecini de iyileştirdiklerini belirtiyor. The Witcher 4 ve Cyberpunk 2’nin ne zaman çıkacağı henüz tam olarak bilinmiyor.

CD Projekt Red: “The Witcher 4 ve Cyberpunk 2’de Cyberpunk 2077’deki hataları tekrarlamayacağız.” yazısı ilk olarak Teknoloji haberleri – LOG üzerinde yayınlandı.

I Shipped a Bug to Production That Cost Us 3 Hours of Downtime

It was a Tuesday afternoon. Nothing felt different about the deploy. Same process as always. Green tests. Approved PR. Merged to main. Deployed.
Seventeen minutes later the alerts started.

What Happened

We had a background job that processed orders and updated inventory counts across our marketplace. The job ran every few minutes and had been running reliably for months without a single issue.

I had made what I genuinely believed was a minor change. A refactor. Cleaner logic, same behavior. I had tested it locally. The tests passed. A colleague reviewed it and approved it. Everything looked fine.

What I had not accounted for was a race condition that only appeared under concurrent load.

In local testing and in our staging environment, the job ran sequentially. In production it ran concurrently across multiple workers. The refactored logic I had written assumed sequential execution. Under concurrent load two workers would occasionally read the same inventory record at the same time, both calculate an update based on the same stale value, and both write back — the second write overwriting the first.

The result was inventory counts that drifted further from reality with every job run. Orders were being accepted for products that were no longer in stock. The customer facing side kept working normally which is why the alerts took seventeen minutes to fire. By the time we caught it the damage was done.

Three hours to identify, roll back, audit the affected records, correct the data, and verify the fix. Three hours of degraded service. A handful of orders that had to be manually cancelled and refunded. A postmortem that took longer than the incident itself.

All of it from a change I had described in the PR as “minor refactor, no behavioral changes.”

The Mistakes I Made

Looking back there were several distinct failures that each independently could have prevented this.

I did not read the existing tests carefully enough.

The existing tests covered the sequential case because the original code had been written with sequential execution in mind. The tests passed because I had not broken the sequential behavior — I had broken the concurrent behavior that the tests never covered. I looked at green tests and assumed coverage. Green tests mean the tested cases pass. They say nothing about the cases that were not tested.

I assumed staging was representative of production.

Our staging environment runs a single worker. Production runs multiple. This difference had never mattered before because nothing we had deployed before this change was sensitive to concurrency. I knew staging was a single worker. I did not think about whether that mattered for this specific change. It did.

I described the change inaccurately in the PR.

“Minor refactor, no behavioral changes” is the kind of description that makes reviewers less careful not more. My colleague approved it quickly because the description signaled low risk. If I had described it accurately — “refactoring the inventory update logic, please check whether the new approach handles concurrent writes correctly” — the review would have been different. The description I wrote was not intentionally misleading. It reflected my own incorrect assessment of the risk. That is worse in some ways. I had convinced myself it was minor before I convinced anyone else.

I had no concurrency testing in my local verification process.

I ran the job locally once and watched it complete successfully. I did not run it concurrently. I did not run it under load. I did not simulate multiple workers. Running a background job once in a local environment and calling it tested is not testing the job — it is testing one execution path under ideal conditions.

What We Changed After

We added a staging environment worker count that matches production.

This sounds obvious in retrospect. It was not obvious until it cost us three hours. Staging now runs the same number of workers as production for any service where concurrency matters. The environment is still not a perfect replica but the concurrency profile now matches.

We added explicit concurrency tests for any job that touches shared state.

Not just unit tests for the logic. Integration tests that spin up multiple workers, run them simultaneously against the same test data, and verify the outcome is consistent. These tests are slower. They are also the only tests that would have caught this specific failure.

We changed how we describe PR risk in reviews.

We added a required field to our PR template: “Concurrency considerations.” It can be filled with “N/A — this change does not touch shared state” which takes ten seconds. For changes that do touch shared state it forces the author to think about and articulate the concurrency implications before a reviewer sees the code. The reviewer then knows to focus there specifically.

We introduced a pre-deploy checklist for background jobs.

Not a long checklist. Four questions. Does this job touch shared state? Does the logic assume sequential execution? Has it been tested under concurrent load? Does the rollback procedure work without manual data correction? The checklist takes two minutes. The incident it is designed to prevent took three hours.

The Thing I Keep Coming Back To

The bug itself was not the most interesting part of this story.
The most interesting part is how many independent checkpoints it sailed through without being caught.

I did not catch it in my own review of the change. My colleague did not catch it in the PR review. The automated tests did not catch it. The staging deployment did not catch it.

Each of those checkpoints failed for a different reason. My self review failed because I had already decided the change was minor and was not looking carefully. The PR review failed because my description set a low risk frame that the reviewer accepted. The automated tests failed because they covered the wrong execution model. Staging failed because the environment did not match production on the one dimension that mattered.
A failure that gets through four independent checkpoints is not bad luck. It is a systems problem. The checkpoints were not actually independent — they were all downstream of my initial incorrect assessment that the change was low risk. Once I had decided it was minor, every subsequent checkpoint was biased toward confirming that assessment.

This is the uncomfortable version of the lesson. It is not just “write better tests” or “stage more carefully.” It is that your own confidence in a change actively degrades the quality of the checks that follow it. The times you are most certain a change is safe are the times you most need to deliberately stress test that certainty.

What I Do Differently Now

Before merging anything that touches background jobs, queues, or shared state I now ask one question regardless of how minor the change feels:
What would have to be true about the execution environment for this to fail silently?

Not loudly. Not with an immediate error that gets caught in tests. Silently — in a way that produces correct looking output under normal conditions and wrong output under specific conditions that do not exist in development or staging.

That question changes how I look at a change. It forces me to think about the gap between the environment where I tested and the environment where it will run.

It would have caught this one. It has caught two potential issues since. Neither of them made it to production.
The best lesson from a production incident is not a new process. It is a new question you ask yourself before you need the process.

Exact Solution is a certified refurbished electronics marketplace shipping across Europe. We stock the best refurbished laptops from Apple, Dell, HP, and Lenovo — all fully tested and ready to ship.

程序人生:在代码与时间之间

文章:程序人生——在代码与时间之间

程序人生 · 随笔

在代码与时间之间

每一行代码,都是某一天你做出的选择。

技术成长独立开发程序员

我第一次写出能运行的程序,是在一台风扇轰鸣的旧电脑上。屏幕上打出 Hello, World! 那一刻,我盯着那几个字看了很久。不是因为觉得自己天才,而是突然意识到——原来我也可以让机器做事。

那是一种很奇特的权力感,轻微但真实。

程序员的时间,是用 bug 计量的

后来才明白,真正的编程生涯并不是一直在创造,更多时候是在修补。你花三小时写出一个功能,再花五小时修它引发的问题,最后用半天重构掉一开始的设计——然后发现,下一个版本还会重蹈覆辙。

这听起来像是西西弗斯的故事。但程序员大多不这么悲观。因为每次推上去的石头,形状都不一样了。

没有一段代码是写完的,只有暂时不动它的代码。而”暂时”,有时意味着永远。

那些深夜与系统的对话

凌晨两点,服务器响应时间突然飙升,告警短信一条接一条。你坐在屏幕前,眼睛发酸,手里端着放凉的咖啡,开始翻日志。

那种状态其实很难描述——焦虑、专注、某种程度上的清醒。外面的世界都睡着了,只有你和这台机器还在对话。你下一条查询语句,它吐出一行数字;你加一个索引,它慢慢把响应时间压下来。

等到监控图上那条曲线终于平复,天差不多也快亮了。你保存好文档,关上电脑,没有什么英雄主义,只是觉得:嗯,可以了。

技术之外,那个慢慢成形的”你”

做了几年开发,你会发现最难的问题往往不是技术问题。架构怎么设计,功能要不要做,边界在哪里——这些决策背后,其实是你对世界的判断,是你的价值观在说话。

一个独立开发者会逐渐明白:代码只是手段,你真正在构建的,是一种能和另一个人建立连接的东西。那份连接能不能成立,取决于你是否真的理解对方的问题。

技术让你有能力解决问题,但只有理解人,你才知道该解决哪个问题。

慢下来,才能走远

程序员的世界变化太快,新框架、新语言、新范式,一年换几茬。有时候跟不上会焦虑,觉得自己要被淘汰了。

但做的时间长了会发现,真正有价值的东西变化并不快。怎么拆解问题、怎么取舍优先级、怎么在不确定中做出还算合理的决定——这些能力,无论什么时代都稀缺。

你不需要追上所有的浪,你只需要站在水里,感受一下水的方向。

写在最后

程序人生,不是一个关于天赋的故事,也不全是关于努力的故事。更多时候,它是关于在一件具体的事上,持续待下去的故事。

你打开编辑器,写下第一行,删掉,再写。时间就这样一点一点过去,某天回头看,发现自己已经走了很远。

那就够了。

The Wrong Way to Think About XRPL Event Infrastructure

Most developers approach XRPL event monitoring like it’s a simple polling problem. They spin up a script that checks wallet balances every few minutes, maybe set up a cron job to scan for new transactions. When they need real-time updates, they build a WebSocket listener that connects directly to the ledger.

This approach breaks down fast. WebSocket connections drop. Rate limits kick in. Transaction parsing gets complex when you’re dealing with DEX trades, escrow releases, and payment channel updates. The script that worked fine in development starts missing events in production.

The correct frame: XRPL event monitoring is an infrastructure problem, not a feature problem.

You need reliable delivery, retry logic, signature verification, and dead-letter queues. You need to handle 22 different event types across seven categories without your application logic becoming a mess of conditional statements. Most importantly, you need this infrastructure to work when your application scales beyond a few users.

I built XRNotify because every XRPL developer was solving the same infrastructure problem independently. The results were predictable: brittle listeners, missed events, no monitoring, and hours spent debugging webhook delivery failures instead of building product features.

The Infrastructure Layer XRPL Was Missing

XRNotify sits between the XRP Ledger and your application. It maintains persistent connections to the network, parses transaction data in real-time, and delivers structured events to your endpoints via webhooks.

The delivery mechanism uses exponential backoff retry with dead-letter queues for failed deliveries. Every payload includes HMAC-SHA256 signatures for verification. When your endpoint is down for maintenance, events queue up and deliver in order once you’re back online.

The event categorization covers wallet activity, DEX operations, escrow management, payment channels, NFT transfers, network state changes, and validator updates. Instead of parsing raw XRPL transaction data, you receive structured JSON with the specific information your application needs.

Beyond Simple Notifications

The real value emerges when you consider XRNotify as part of a larger data infrastructure. Network state data flows to The Neutral Bridge for financial research. Anomaly patterns feed into H.U.N.I.E.’s intelligence layer. The same infrastructure that delivers your application webhooks also powers the circuit breaker logic in H.U.N.I.E. Sentinel.

This isn’t accidental architecture. XRPL generates massive amounts of transaction data, but most of it gets processed once and discarded. XRNotify captures that data at the moment it becomes available, then routes it to multiple systems that extract different types of value.

Your application gets the specific events it needs. The research infrastructure gets comprehensive network state data. The security layer gets real-time anomaly detection. The same infrastructure investment serves multiple purposes.

Technical Implementation

The system runs on Next.js 14 with PostgreSQL for persistence and Redis for event queuing. Node.js workers handle the XRPL connections and webhook delivery. XRPL.js provides the ledger interface, but the heavy lifting happens in the retry logic and delivery infrastructure.

The webhook endpoints receive POST requests with JSON payloads. Each payload includes event metadata, transaction details, and the HMAC signature for verification. Your application validates the signature, processes the event, and returns a 200 status code. If validation fails or processing errors occur, the retry mechanism handles redelivery.

Event filtering happens at the subscription level. You specify which wallets to monitor, which event types to receive, and which endpoint should handle each category. The infrastructure handles the complexity of maintaining multiple XRPL connections, parsing different transaction types, and routing events to the correct destinations.

The Result

Developers connect their systems to XRPL events without building listener infrastructure. Applications respond to network activity in real-time without polling delays. The webhook delivery guarantees mean events reach your system even during deployment downtime or temporary outages.

This is infrastructure that works when you need it to work. No missed events, no parsing edge cases, no webhook delivery debugging at 2 AM.

https://www.xrnotify.io

What I Learned About MND, Voice Banking, and Why Assistive Tech Is Personal

As part of my volunteering with AbilityNet, I recently attended a training session on motor neurone disease (MND) — and it quietly shifted how I think about what technology is actually for.

What is MND?

MND affects nerves in the brain and spinal cord called motor neurones — the nerve cells that tell your muscles what to do. It’s a rare but progressive condition. There is no cure, and it’s usually life-shortening.

Around 5,000 people in the UK are living with MND at any one time. Six people are diagnosed every single day.

Voice banking

Voice banking was something I hadn’t encountered before this session. It allows a person to record their own voice or create personalised messages for use with speech-generating devices — and the MND Association recommends starting early, even before speech is significantly affected.

The goal is to preserve something that can’t be replaced once it’s gone: the sound of you.

Research shows that “preserving identity” is the overarching motivation for people who choose to voice bank. Yet only around 12% of people diagnosed with MND in the UK actually go through with it — because the process has historically been too long, too hard, and too clinical.

That gap between what’s available and what people can actually access is exactly where technology and human support need to meet.

Good technology serves the people

You don’t need to be in tech to show up for this. Anyone can volunteer, learn, and help bridge that gap.

If you want to understand more about what living with MND actually looks like, this is a good place to start:

Further reading:

$1.50/Month Email Infrastructure That Beats Your $20 SendGrid Plan

Stop Paying $20/Month for Email You Could Run for $1.50

Most teams handle programmatic email one of two ways.

The first: sign up for SendGrid or Mailgun, grab an API key, and start sending. It works great — until you hit 50K emails and the bill jumps to $20/month, then you add inbound parsing and it’s another plan tier, then you need bounce webhooks and suddenly you’re deep in a third-party dashboard you barely understand, paying for features you don’t use.

The second: use Google Workspace’s catch-all rule to funnel everything into one inbox. Free-ish, but completely dumb — no processing, no routing, no automation. Every contact form submission and support@ email lands in the same pile for a human to sort manually.

There’s a better way that most AWS developers walk right past. Amazon SES catches the mail, S3 stores it, Lambda processes it. The logic lives in your codebase, the infrastructure is fully in your AWS account, and the bill last month was $1.48.

Let me show you exactly how it works.

The idea in one diagram

ses

That’s it. SES stores the raw email in S3, then fires Lambda. Lambda rewrites the headers and re-sends via SES. Your team gets the email; they hit Reply; it goes back to whoever sent it.

The S3 step isn’t optional — it’s actually the smart part. Every email is durably stored before Lambda touches it. Parser bug? Replay from S3. Need an audit trail? It’s there, with 30-day auto-expiry built in.

This is not a replacement for Google Workspace or AWS WorkMail

Worth saying clearly before going further.

Google Workspace and AWS WorkMail are mailbox products — your team logs in, reads emails, manages a calendar, sends from their phone. That’s a completely different problem from what this stack solves.

SES + Lambda + S3 Google Workspace / WorkMail
Who uses it Your code Your people
Interface API / SDK Gmail, Outlook, mobile apps
Use case Contact forms, alerts, forwarding, automation Human inboxes, calendar, collaboration
Handles attachments Yes, programmatically Yes, in a UI
IMAP / POP3 No Yes
Shared calendars No Yes
Price ~$1–50/month flat $6–12/user/month

The honest recommendation: run both. Keep Workspace or WorkMail for your team’s human mailboxes. Use this stack for everything your application touches — the support@ alias that should file a ticket, the contact@ that should notify Slack, the noreply@ that sends password resets.

They’re not competing. They solve different problems.

Why not just use SendGrid / Postmark / Mailgun?

Nothing wrong with them. But look at the math for a typical small company:

10K emails/month 500K emails/month
SES + Lambda + S3 ~$1.50 ~$55
Postmark $15 ~$650
SendGrid Essentials $19.95 $89.95+
Mailgun $35 $90+
Self-hosted VPS ~$15 + your time same + more time

SES is $0.10 per 1,000 emails. That rate has not changed since 2011. At 500K sends, you’re paying $50 while Postmark charges $650 for the same volume. The gap is real.

The catch: SES is infrastructure, not a product. No drag-and-drop campaign builder, no deliverability dashboard (unless you pay extra for VDM). If you want someone to hold your hand through email, pay for managed. If you’re comfortable in AWS, this stack is hard to beat.

Keep Google Workspace or Microsoft 365 for your human mailboxes. This pattern is for the email your code handles — contact forms, support@ aliases, alerts, password resets.

What the deploy actually creates

One SAM template provisions everything:

  • S3 bucket — stores raw emails under inbox/, auto-deletes after 30 days
  • SES identity — verifies your domain with DKIM signing enabled
  • Route 53 records — MX, SPF, DMARC, and all 3 DKIM CNAMEs, created automatically
  • Lambda + IAM — reads from S3, forwards via SES
  • Receipt rule — catches all mail at your domain, stores then invokes Lambda
  • Custom resource — activates the rule set automatically so you don’t have to
sam build
sam deploy --guided 
  --parameter-overrides 
    EmailDomain=yourcompany.com 
    ForwardTo=team@yourcompany.com 
    ForwardFrom=relay@yourcompany.com 
    HostedZoneId=ZXXXXXXXXXXXXX

One command. Everything wired up.

The code

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Serverless email infrastructure (SES + S3 + Lambda)

Parameters:
  EmailDomain:
    Type: String
    Description: Domain configured to receive email (e.g. yourcompany.com)
  ForwardTo:
    Type: String
    Description: Address to forward incoming mail to
  ForwardFrom:
    Type: String
    Description: Verified sender used as the From on forwards
  HostedZoneId:
    Type: String
    Description: Route 53 Hosted Zone ID for the domain

Resources:

  # -------------------------------------------------------
  # S3 — raw email storage
  # -------------------------------------------------------
  EmailBucket:
    Type: AWS::S3::Bucket
    Properties:
      LifecycleConfiguration:
        Rules:
          - Id: ExpireRawEmails
            Status: Enabled
            ExpirationInDays: 30

  EmailBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref EmailBucket
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Sid: AllowSESPuts
            Effect: Allow
            Principal:
              Service: ses.amazonaws.com
            Action: s3:PutObject
            Resource: !Sub '${EmailBucket.Arn}/*'
            Condition:
              StringEquals:
                AWS:SourceAccount: !Ref AWS::AccountId

  # -------------------------------------------------------
  # Lambda — parse and forward
  # -------------------------------------------------------
  ProcessEmailFunction:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.12
      Handler: app.lambda_handler
      CodeUri: ./src/
      MemorySize: 256
      Timeout: 30
      Environment:
        Variables:
          FORWARD_TO: !Ref ForwardTo
          FORWARD_FROM: !Ref ForwardFrom
          BUCKET_NAME: !Ref EmailBucket
          BUCKET_PREFIX: inbox/
      Policies:
        - S3ReadPolicy:
            BucketName: !Ref EmailBucket
        - Statement:
            - Effect: Allow
              Action: ses:SendRawEmail
              Resource: '*'

  ProcessEmailFunctionSESPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt ProcessEmailFunction.Arn
      Principal: ses.amazonaws.com
      SourceAccount: !Ref AWS::AccountId

  # -------------------------------------------------------
  # SES — identity, receipt rule set and rule
  # -------------------------------------------------------
  SESEmailIdentity:
    Type: AWS::SES::EmailIdentity
    Properties:
      EmailIdentity: !Ref EmailDomain
      DkimAttributes:
        SigningEnabled: true

  ReceiptRuleSet:
    Type: AWS::SES::ReceiptRuleSet
    Properties:
      RuleSetName: !Sub '${AWS::StackName}-rules'

  ReceiptRule:
    Type: AWS::SES::ReceiptRule
    DependsOn:
      - EmailBucketPolicy
      - SESEmailIdentity
    Properties:
      RuleSetName: !Ref ReceiptRuleSet
      Rule:
        Name: store-and-process
        Enabled: true
        ScanEnabled: true
        Recipients:
          - !Ref EmailDomain
        Actions:
          - S3Action:
              BucketName: !Ref EmailBucket
              ObjectKeyPrefix: inbox/
          - LambdaAction:
              FunctionArn: !GetAtt ProcessEmailFunction.Arn
              InvocationType: Event

  # -------------------------------------------------------
  # Route 53 — all DNS records created automatically
  # -------------------------------------------------------
  DkimRecord1:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneId: !Ref HostedZoneId
      Name: !Sub "${SESEmailIdentity.DkimDNSTokenName1}"
      Type: CNAME
      TTL: 300
      ResourceRecords:
        - !Sub "${SESEmailIdentity.DkimDNSTokenValue1}"

  DkimRecord2:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneId: !Ref HostedZoneId
      Name: !Sub "${SESEmailIdentity.DkimDNSTokenName2}"
      Type: CNAME
      TTL: 300
      ResourceRecords:
        - !Sub "${SESEmailIdentity.DkimDNSTokenValue2}"

  DkimRecord3:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneId: !Ref HostedZoneId
      Name: !Sub "${SESEmailIdentity.DkimDNSTokenName3}"
      Type: CNAME
      TTL: 300
      ResourceRecords:
        - !Sub "${SESEmailIdentity.DkimDNSTokenValue3}"

  MXRecord:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneId: !Ref HostedZoneId
      Name: !Sub "${EmailDomain}."
      Type: MX
      TTL: 300
      ResourceRecords:
        - !Sub "10 inbound-smtp.${AWS::Region}.amazonaws.com"

  SPFRecord:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneId: !Ref HostedZoneId
      Name: !Sub "${EmailDomain}."
      Type: TXT
      TTL: 300
      ResourceRecords:
        - '"v=spf1 include:amazonses.com -all"'

  DMARCRecord:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneId: !Ref HostedZoneId
      Name: !Sub "_dmarc.${EmailDomain}."
      Type: TXT
      TTL: 300
      ResourceRecords:
        - !Sub '"v=DMARC1; p=none; rua=mailto:dmarc@${EmailDomain}"'

  # -------------------------------------------------------
  # Custom resource — activates the rule set automatically
  # -------------------------------------------------------
  ActivateRuleSetFunction:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: python3.12
      Handler: index.handler
      InlineCode: |
        import boto3, cfnresponse
        def handler(event, context):
          try:
            if event['RequestType'] in ('Create', 'Update'):
              boto3.client('ses').set_active_receipt_rule_set(
                RuleSetName=event['ResourceProperties']['RuleSetName']
              )
            cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
          except Exception as e:
            cfnresponse.send(event, context, cfnresponse.FAILED, {'Error': str(e)})
      Policies:
        - Statement:
            - Effect: Allow
              Action: ses:SetActiveReceiptRuleSet
              Resource: '*'

  ActivateRuleSet:
    Type: AWS::CloudFormation::CustomResource
    DependsOn: ReceiptRuleSet
    Properties:
      ServiceToken: !GetAtt ActivateRuleSetFunction.Arn
      RuleSetName: !Ref ReceiptRuleSet

Outputs:
  BucketName:
    Description: S3 bucket storing raw incoming emails
    Value: !Ref EmailBucket
  RuleSetName:
    Description: Active receipt rule set
    Value: !Ref ReceiptRuleSet
  LambdaFunction:
    Description: Email processing function
    Value: !GetAtt ProcessEmailFunction.Arn

src/app.py

The key insight here is to mutate the original message in-place rather than building a new MIME object from scratch. This preserves attachments, encoding, and HTML content without any extra parsing. The critical step is stripping the headers that would cause DKIM verification to fail on the re-sent message.

import os
import email
import logging

import boto3

logger = logging.getLogger()
logger.setLevel(logging.INFO)

s3  = boto3.client("s3")
ses = boto3.client("ses")

FORWARD_TO    = os.environ["FORWARD_TO"]
FORWARD_FROM  = os.environ["FORWARD_FROM"]
BUCKET_NAME   = os.environ["BUCKET_NAME"]
BUCKET_PREFIX = os.environ["BUCKET_PREFIX"]  # "inbox/"

# These headers are tied to the original message signature.
# Keeping them causes DKIM failure on the forwarded copy.
HEADERS_TO_REMOVE = [
    "DKIM-Signature",
    "Sender",
    "Return-Path",
    "Message-ID",
]


def lambda_handler(event, context):
    for record in event["Records"]:
        mail    = record["ses"]["mail"]
        receipt = record["ses"]["receipt"]

        msg_id     = mail["messageId"]
        recipients = receipt["recipients"]
        s3_key     = f"{BUCKET_PREFIX}{msg_id}"

        logger.info("Processing %s for recipients %s", msg_id, recipients)

        raw = s3.get_object(Bucket=BUCKET_NAME, Key=s3_key)["Body"].read()
        msg = email.message_from_bytes(raw)

        original_from    = msg.get("From", "unknown")
        original_subject = msg.get("Subject", "(no subject)")

        for header in HEADERS_TO_REMOVE:
            if header in msg:
                del msg[header]

        # Rewrite From so SES accepts the send
        if "From" in msg:
            msg.replace_header("From", FORWARD_FROM)
        else:
            msg["From"] = FORWARD_FROM

        # Reply-To preserves the original sender — your team hits Reply
        # and it goes back to the person who sent it, not to your relay address
        if "Reply-To" in msg:
            msg.replace_header("Reply-To", original_from)
        else:
            msg["Reply-To"] = original_from

        # Subject prefix tells you which alias received it
        original_recipient = recipients[0] if recipients else ""
        new_subject = f"[{original_recipient}] {original_subject}"
        if "Subject" in msg:
            msg.replace_header("Subject", new_subject)
        else:
            msg["Subject"] = new_subject

        if "To" in msg:
            msg.replace_header("To", FORWARD_TO)
        else:
            msg["To"] = FORWARD_TO

        ses.send_raw_email(
            Source=FORWARD_FROM,
            Destinations=[FORWARD_TO],
            RawMessage={"Data": msg.as_bytes()},
        )
        logger.info("Forwarded %s → %s → %s", msg_id, original_recipient, FORWARD_TO)

    return {"statusCode": 200}

Deploying it

Prerequisites: AWS CLI configured, SAM CLI v1.129+, a domain in Route 53.

1. Get your hosted zone ID

aws route53 list-hosted-zones 
  --query "HostedZones[?Name=='yourcompany.com.'].{Id:Id,Name:Name}"

2. Build and deploy

sam build
sam deploy --guided 
  --parameter-overrides 
    EmailDomain=yourcompany.com 
    ForwardTo=team@yourcompany.com 
    ForwardFrom=relay@yourcompany.com 
    HostedZoneId=ZXXXXXXXXXXXXX

SAM will ask a few questions on first run — accept the defaults, confirm the changeset. The deploy creates everything: S3, Lambda, SES identity, all DNS records, and activates the rule set automatically.

3. Request production access

New SES accounts are sandboxed — you can only send to verified addresses until you request production access. Go to SES Console → Account dashboard → Request production access, describe your use case in two sentences. AWS typically responds within 24 hours.

4. Test it

Send an email to any address at your domain and watch the logs:

aws logs tail /aws/lambda/YOUR_STACK_NAME-ProcessEmailFunction 
  --follow 
  --region us-east-1

You should see the message ID logged, then a forwarded confirmation within a couple of seconds.

If domain verification stays Pending

After deploy, SES verifies your domain by polling the DKIM CNAME records. It usually completes within 5–10 minutes. If it’s been 30+ minutes and still shows Pending, check that DNS is actually resolving:

# Replace with one of your actual DKIM token values
nslookup -type=CNAME abc123._domainkey.yourcompany.com 8.8.8.8

If that resolves correctly, nudge SES to recheck immediately rather than waiting for its next polling cycle:

aws ses verify-domain-identity 
  --domain yourcompany.com 
  --region us-east-1

# Then check status
aws ses get-identity-verification-attributes 
  --identities yourcompany.com 
  --region us-east-1

If DNS isn’t resolving at all, the CNAME records weren’t created cleanly — check the CloudFormation events for any Route 53 errors during deploy.

A few gotchas learned the hard way

SES only allows one active rule set per region per account. If you have multiple environments in the same region, they share this single slot. The solution is one rule set with multiple rules — one rule per domain — rather than multiple rule sets competing for the active slot.

New accounts start in sandbox mode. You can only send to verified addresses until you request production access. Do it on day one — AWS usually approves within 24 hours and there’s no reason to wait.

Not every region supports inbound. Stick to us-east-1, us-west-2, or eu-west-1. Pick your region before pointing your MX record.

Watch your bounces. SES will throttle your account if you keep sending to addresses that bounce. Enable Account-Level Suppression in the SES console — it’s free and it protects you automatically.

Want to go further?

This same stack is the backbone for a contact form endpoint — a Lambda Function URL receives the POST, validates the fields, and calls ses.send_email. Same verified domain, same IAM, a few dozen extra lines. Add a honeypot field and you get basic bot protection for free.

You can also extend the forwarder to route different aliases to different people (support@ → helpdesk, billing@ → finance) by checking recipients[0] in the Lambda and maintaining a simple routing table in the code or in DynamoDB.

The foundation is solid. Build on it.

Bypassing Payment Platform Restrictions Was The Best Decision I Ever Made For My Digital Product Business

The Problem We Were Actually Solving

I was trying to sell digital products online from a country that was blocked by most major payment platforms, including PayPal, Stripe, Gumroad, and Payhip. As the founder of a small business, this was a major obstacle to my ability to reach customers and generate revenue. I spent countless hours researching alternative payment solutions, but every door I tried to open seemed to be met with a new set of restrictions and requirements. It was clear that this was a platform problem, not a problem with my business or my products.

What We Tried First (And Why It Failed)

My first approach was to try to use VPNs and other workarounds to pretend to be located in a country that was not blocked by these platforms. However, this approach was not only risky and potentially illegal, but it also did not work. The payment platforms had sophisticated systems in place to detect and prevent this type of activity, and I was quickly flagged and banned from using their services. I also tried to use smaller, lesser-known payment platforms, but these often had their own set of problems, including high fees, poor customer support, and limited functionality. After months of trying different solutions, I was on the verge of giving up.

The Architecture Decision

It was at this point that I made the decision to take a completely different approach. Instead of trying to work with existing payment platforms, I decided to build my own custom payment solution using a combination of open-source software and local payment providers. This was a daunting task, as it would require significant development and testing resources, but I was convinced that it was the only way to truly bypass the restrictions and achieve my business goals. I chose to use a modular architecture, with separate components for payment processing, order management, and customer management. This would allow me to easily integrate with different payment providers and switch between them as needed. I also decided to use a microservices-based approach, with each component running as a separate service. This would provide greater flexibility and scalability, and would allow me to quickly respond to changes in the market or in the requirements of my business.

What The Numbers Said After

The results of this decision were nothing short of astonishing. Within six months, I was able to launch my custom payment solution and start processing transactions. The fees were significantly lower than those charged by the major payment platforms, and the customer support was vastly improved. I was also able to customize the solution to meet the specific needs of my business, including integrating with my existing order management and customer management systems. In terms of metrics, I saw a 30% increase in sales within the first year, and a 25% decrease in payment processing fees. The solution also provided greater scalability and reliability, with uptime of 99.99% and average transaction processing time of under 2 seconds. I used tools such as Prometheus and Grafana to monitor the performance of the system, and was able to quickly identify and resolve any issues that arose.

What I Would Do Differently

In retrospect, I would have made the decision to build a custom payment solution much earlier. I would have also invested more in testing and quality assurance, as this would have helped to identify and resolve issues more quickly. I would also have chosen to use more cloud-based services, such as AWS Lambda and API Gateway, to provide greater scalability and flexibility. Additionally, I would have implemented more robust security measures, such as two-factor authentication and encryption, to protect sensitive customer data. However, overall, I am extremely satisfied with the decision to build a custom payment solution, and I believe that it has been a key factor in the success of my business. I used tools such as Jenkins and GitLab CI/CD to automate the testing and deployment of the solution, and was able to quickly and easily make changes to the system as needed.