When I first started experimenting with Umbraco Automate, I wanted to find a real-world use case instead of creating an integration just for the sake of trying the API. It didn't take long to find one. On several Umbraco projects, we use Cloudflare in front of the website. When content changes, there are situations where we want to invalidate the cached version of the affected page.
Traditionally, I would solve this in code:
Content published
↓
Notification handler
↓
Determine published URLs
↓
Call Cloudflare API
↓
Purge cache
That works. But after looking at Automate, I started wondering:
What if the application doesn't need to know anything about Cloudflare at all?
Instead, publishing content could simply trigger an automation, with small reusable actions taking care of the individual steps.
That idea eventually resulted in three NuGet packages:
Umbraco.Community.AutomateUmbraco.Community.Automate.ExtensionsUmbraco.Community.Automate.Cloudflare
All currently available as version 17.0.0 for Umbraco 17.
This post describes how I got there, some of the problems I encountered, and what I learned about extending Umbraco Automate along the way.
Starting with the obvious solution
My first idea was simple. Umbraco Automate already provides a Content Published trigger, so I wanted to create a Cloudflare action that could take the published page and purge it from Cloudflare.
Something like:
Content Published
↓
Purge Cloudflare
The Cloudflare API itself makes this fairly straightforward.To purge specific URLs you can send a request to:
POST /client/v4/zones/{zoneId}/purge_cache
with:
{
"files": [
"https://www.example.com/some-page/"
]
}
So initially I thought the Cloudflare action could simply accept the URL of the published page.
The Content Published trigger doesn't provide a URL.
It exposes information such as:
contentKey
contentName
contentTypeKey
contentTypeAlias
cultures
And actually, that makes sense. A URL isn't necessarily a property of the publish event.
A content item doesn't necessarily have one URL
This became the first interesting part of the implementation.
Consider a multilingual Umbraco website:
https://www.example.com/
https://www.example.com/en/
Now add multiple hostnames, which is perfectly valid in an Umbraco installation:
https://www.example-a.com/
https://www.example-a.com/en/
https://www.example-b.com/
https://www.example-b.com/en/
For a single content item, asking:
What is the URL?
is therefore not always the right question.
The better question is:
What are the published URLs for this content item and culture?
That distinction ended up having quite a large influence on the package design.
Separating Umbraco from Cloudflare
My first instinct was to put the URL resolution inside the Cloudflare action.
The action could receive:
Content Key
Culture
and internally:
- Resolve the Umbraco content.
- Determine its URLs.
- Call Cloudflare.
- Purge those URLs.
But that didn't feel right.
Resolving published URLs has nothing to do with Cloudflare.
The same functionality could be useful when:
- notifying an external API;
- updating a search index;
- sending a webhook;
- invalidating another CDN;
- generating a sitemap;
- or building another Automate workflow.
So instead I created a generic action:
Get Published Content URLs
and moved it into a separate package:
Umbraco.Community.Automate.Extensions
Cloudflare became its own integration:
Umbraco.Community.Automate.Cloudflare
This allows Automate to do what it is good at: composing small pieces of functionality.
The workflow becomes:
Content Published
↓
For Each Culture
↓
Get Published Content URLs
↓
Purge Cloudflare URLs
That separation is probably the design decision I'm happiest with.
Building Get Published Content URLs
The generic action receives a content key and culture.
Its settings are roughly:
public sealed class GetPublishedContentUrlsSettings
{
public Guid ContentKey { get; set; }
public string Culture { get; set; } = string.Empty;
}
The output deliberately contains an array:
public sealed class GetPublishedContentUrlsOutput
{
public Guid ContentKey { get; set; }
public string Culture { get; set; } = string.Empty;
public string[] Urls { get; set; } = [];
}
Notice that it's Urls, not Url.
This is important for installations where a culture is available through multiple domains.
Conceptually the action does:
Content Key + Culture
↓
Umbraco published content
↓
Primary + alternative URLs
↓
string[]
That output can then be consumed by any other Automate action.
Lesson learned: be careful with service lifetimes
While building the action I ran into another useful lesson.
My first implementation injected IPublishedContentQuery directly into the action.
That resulted in:
Cannot consume scoped service
'Umbraco.Cms.Core.IPublishedContentQuery'
from singleton
'GetPublishedContentUrlsAction'.
Automate actions are registered as singletons, while several Umbraco services used for published content are scoped.
Injecting one directly into a singleton therefore isn't safe.
Instead, the action needs to work with the appropriate Umbraco context/service scope when resolving published content.
It's an easy mistake to make when building your first Automate extension because the action itself looks very similar to a normal service.
The important takeaway:
Always consider the lifetime of the services you inject into an Automate action.
Cultures introduced another interesting problem
The Content Published trigger exposes cultures.
Not culture.
That means a single publish operation can involve multiple cultures.
The obvious workflow is therefore:
Content Published
↓
For Each
↓
Get Published Content URLs
The collection for the loop is:
${ trigger.cultures }
And inside the loop the current culture is:
${ loop.item }
Initially I accidentally passed:
${ trigger.cultures }
directly into the action's Culture field.
That resulted in something like:
["es"]
being passed as the culture instead of:
es
and consequently:
Content '<guid>' is not published for culture '["es"]'.
The correct pattern is:
For Each:
${ trigger.cultures }
Get Published Content URLs:
Content Key:
${ trigger.contentKey }
Culture:
${ loop.item }
A small mistake, but a good example of why understanding the difference between a collection binding and the current loop item matters.
Then I wanted to purge multiple URLs at once
Initially the Cloudflare package had:
Purge Cloudflare URL
with:
public string Url { get; set; } = string.Empty;
This worked perfectly when combined with another For Each:
Get Published Content URLs
↓
For Each URL
↓
Purge Cloudflare URL
But Cloudflare already accepts multiple URLs in one request.
So why make one HTTP request per URL?
I changed the action to:
Purge Cloudflare URLs
and the Cloudflare client now accepts a collection:
Task PurgeUrlsAsync(
string apiToken,
string zoneId,
IEnumerable<string> urls,
CancellationToken cancellationToken = default);
The resulting request can contain all URLs:
{
"files": [
"https://www.example-a.com/en/",
"https://www.example-b.com/en/"
]
}
This makes the workflow much nicer:
Get Published Content URLs
↓
Purge Cloudflare URLs
At least, that was the idea.
And then I learned something interesting about Automate bindings.
Binding an array isn't the same as binding a string
My first settings model looked perfectly reasonable:
public sealed class PurgeUrlsSettings
{
public string[] Urls { get; set; } = [];
}
The previous action already returned:
string[] Urls
so I expected this binding to work:
${ previous.urls }
Instead Automate failed before the action was even executed:
Failed to resolve model 'community.cloudflare.purgeUrls'
to type PurgeUrlsSettings.
The JSON value could not be converted to System.String[].
That was confusing at first because the source value really was a string[].
The important detail is how action settings are stored.
The binding itself is initially represented as:
{
"urls": "${ previous.urls }"
}
That's a string expression.
If the property is already declared as string[], JSON deserialization needs to happen before that expression can resolve to its collection value.
So:
"${ previous.urls }"
↓
Deserialize as string[]
↓
💥
Looking at For Each provided the answer
The interesting thing was that this already worked:
Get Published Content URLs
↓
For Each
Collection:
${ previous.urls }
So Automate clearly could consume my array.
That led me into the Automate source code.
The built-in For Each doesn't define its collection setting as an array.
Instead, conceptually it does this:
public string Collection { get; set; } = string.Empty;
The setting contains the binding expression, not the resolved collection.
The expression is evaluated later at runtime.
That same approach works nicely for the Cloudflare action.
The setting can remain:
public sealed class PurgeUrlsSettings
{
public string Urls { get; set; } = string.Empty;
}
and the configured value remains:
${ previous.urls }
At runtime, Automate's BindingEvaluator can evaluate the raw expression:
var value = _bindingEvaluator.EvaluateRaw(
settings.Urls,
context.BindingData
?? new Dictionary<string, object?>());
The action can then turn that resolved value into the collection it needs.
This was probably the most interesting technical lesson from building the package.
Sometimes the type of a configuration property shouldn't represent the type of the eventual runtime value.
In this case:
Setting
string expression
↓
BindingEvaluator
↓
runtime collection
↓
Cloudflare API
is a better model.
Normalizing the URLs
Before sending anything to Cloudflare, the action normalizes the collection:
urls = urls
.Where(url => !string.IsNullOrWhiteSpace(url))
.Select(url => url.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
Then it verifies that something remains:
if (urls.Length == 0)
{
return ActionResult.Failed(
new ArgumentException(
"At least one URL is required."));
}
and validates the URLs:
var invalidUrl = Array.Find(
urls,
url => !Uri.TryCreate(
url,
UriKind.Absolute,
out _));
if (invalidUrl is not null)
{
return ActionResult.Failed(
new ArgumentException(
$"'{invalidUrl}' is not a valid absolute URL."));
}
Only after that does the action call Cloudflare.
Creating a reusable Cloudflare connection
I didn't want API tokens and zone IDs to be configured on every action.
Automate has a connection concept for exactly this purpose, so the Cloudflare package provides its own connection type.
The connection contains information such as:
Account ID
Zone ID
API Token
The API token can also be validated against Cloudflare.
Cloudflare provides an endpoint for checking whether a token is valid and active, which makes the connection test much more useful than simply checking whether a value was entered.
A successful response looks roughly like:
{
"result": {
"status": "active"
},
"success": true
}
This gives users feedback while configuring the connection instead of waiting for their first automation run to fail.
Keep API tokens scoped
For the Cloudflare API token, I recommend granting only the permissions required by the workflow.
For cache purging, don't use a token with unnecessary account-wide permissions.
The connection should know:
API Token
Zone ID
and the action should only perform the operation it was designed for.
It's a small detail, but integrations like these make security boundaries very visible.
The resulting packages
The experiment eventually became three packages.
Umbraco.Community.Automate
The convenience package.
Installing it brings in the complete collection:
dotnet add package Umbraco.Community.Automate
Umbraco.Community.Automate.Extensions
Generic Automate building blocks:
dotnet add package Umbraco.Community.Automate.Extensions
Currently including:
Get Published Content URLs
Umbraco.Community.Automate.Cloudflare
The Cloudflare integration:
dotnet add package Umbraco.Community.Automate.Cloudflare
Currently providing the Cloudflare connection and cache purge functionality.
The packages are versioned alongside their supported Umbraco major version, so the first stable release is:
17.0.0
for Umbraco 17.
Why I like this approach
The part I like most isn't actually the Cloudflare API integration.
Calling an HTTP API isn't particularly complicated.
The interesting part is the separation of responsibilities.
Instead of writing:
public class ContentPublishedHandler
{
// Find content
// Determine cultures
// Determine domains
// Resolve URLs
// Read Cloudflare configuration
// Call Cloudflare
// Handle failures
}
we can create reusable building blocks:
Content Published
↓
For Each Culture
↓
Get Published Content URLs
↓
Purge Cloudflare URLs
And tomorrow somebody could build:
Content Published
↓
Get Published Content URLs
↓
Notify external service
or:
Content Published
↓
Get Published Content URLs
↓
Update search index
without changing Get Published Content URLs.
That's where I think Automate becomes really interesting.
What I learned
A few things stood out while building these packages.
Actions should do one thing
Get Published Content URLs shouldn't know Cloudflare exists.
And Purge Cloudflare URLs shouldn't need to know anything about Umbraco content.
The workflow connects them.
Model collections explicitly
A published content item can have multiple cultures, and one culture can have multiple URLs.
Designing around collections from the start avoids assumptions that only work for simple websites.
Bindings are runtime values
A binding expression such as:
${ previous.urls }
is stored as a string but may resolve to something completely different at runtime.
Looking at how Automate's own For Each implementation handles this was extremely useful.
Watch your DI lifetimes
Automate actions and Umbraco scoped services don't necessarily share the same lifetime.
Be careful when injecting services such as published-content APIs directly into actions.
Composition beats coupling
The Cloudflare use case resulted in a generic action that turned out to be useful completely independently of Cloudflare.
That's a good sign that the responsibility belongs in its own extension.
What's next?
There are plenty of directions this could go.
Cloudflare supports more than purging individual URLs, so possible future actions include:
Purge by cache tag
Purge by prefix
Purge by hostname
Purge everything
But I don't want one giant "Cloudflare action" with every possible option.
I'd rather keep each operation explicit and let Automate compose them.
The generic Extensions package also opens the door to other reusable Umbraco actions that aren't tied to a specific integration.
And that's probably what I find most interesting about Automate: once you start thinking in small triggers, actions and outputs, you quickly start seeing automation opportunities everywhere.
Try it
The packages are available on NuGet:
Umbraco.Community.Automate
Umbraco.Community.Automate.Extensions
Umbraco.Community.Automate.Cloudflare
Source code, examples and issues are available on GitHub:
https://github.com/erikjanwestendorp/Umbraco.Community.Automate
The project is open source, so feedback, ideas, issues and pull requests are very welcome.
If you're experimenting with Umbraco Automate as well, I'd love to hear what kind of integrations or reusable actions you're building.
Top comments (0)