Umbraco package
The CitationLab package for Umbraco
CitationLab.Umbraco connects an Umbraco site to AI Marketing Manager. Once paired, Manager can read your content and update SEO metadata, structured data, media and redirects, and your developers get a documented REST API on your own domain. Written for site owners, Umbraco developers and the agencies that run sites for clients.
Package at a glance
- Package
- CitationLab.Umbraco
- Version
- 2.0.0
- Umbraco
- 13 LTS
- Runtime
- .NET 8
- License
- MIT
dotnet add package CitationLab.UmbracoRequirements
Check these before you install.
Umbraco 13 LTS on .NET 8
This is the version the package targets.
Umbraco 14 and later: not yet
Umbraco 14 changed how backoffice extensions are built, so the dashboard has to be rewritten before the package supports it. The API side is not affected by that change.
Outbound HTTPS
The web server must be able to reach manager.citationlab.ai over HTTPS, or pairing fails.
An admin user
Pairing and the feature switches live in a dashboard only users in Umbraco's admin group can see.
1. Install
Add the package to the project that runs your Umbraco site.
dotnet add package CitationLab.Umbraco- 1
Add the package
From the folder of your Umbraco web project, run dotnet add package CitationLab.Umbraco.
- 2
Rebuild
Build the solution the way you normally do and deploy it.
- 3
Restart the site
On start-up the package creates its own database tables. No content or template is touched.
Optional configuration
You only need configuration to point the package at a different Manager, such as a staging environment. Without it, the package talks to manager.citationlab.ai.
{
"CitationLab": {
"ManagerUrl": "https://manager.citationlab.ai"
}
}Errors from the package are written to the normal Umbraco log with the prefix [citationlab], so you can filter for them.
2. Pair with AI Marketing Manager
Pairing gives the site a permanent API key and tells Manager where the site lives. It takes a token from Manager and one click in the Umbraco backoffice.
- 1
Generate a pairing token in Manager
In AI Marketing Manager, open the project and go to Integrations → Umbraco → Connect, then choose “Generate pairing token”.
- 2
Open the CitationLab dashboard
Sign in to the Umbraco backoffice as an admin, open the Content section and select the “CitationLab” dashboard.
- 3
Paste the token and click “Pair”
The site sends the token, its own URL and basic site information to Manager, and receives its API key in return. The dashboard then shows the site as connected.
- A token works once and expires after 15 minutes. If it runs out, generate a new one.
- Pair from the backoffice on your public hostname. Manager stores the address you were on when you clicked Pair, so pairing from localhost or an internal name leaves Manager with an address it cannot use.
- Pairing needs a backoffice login on purpose: it decides who may read and write the whole site, so knowing the site's URL is not enough.
- One Umbraco site per project. Pairing a project that is already connected replaces the URL and key of that connection.
- To disconnect, use “Disconnect” in the dashboard, which removes the key stored on the site, or remove the connection in Manager, which deletes the connection and Manager's cached copy of your content. Removing it in Manager does not change anything on the Umbraco site.
3. Choose what the package does
Installing the package changes no page on your site. Only the sync group is on from the start, so Manager can read your content. Everything that would change your HTML, handle requests or collect visitor data stays off until you turn it on.
| Group | Default | What it covers | Switch keys |
|---|---|---|---|
| Sync | On | Reading content, SEO fields, media, taxonomy and document types, and the package's audit log of every write. | content_syncseo_syncmedia_accesstaxonomy_syncdocument_type_syncaudit_log |
| Page output | Off | What the package writes into your pages and site files: meta and Open Graph tags, structured data, breadcrumbs, custom and verification tags, robots.txt, sitemap, llms.txt and other AI-facing files. | meta_outputschema_outputog_outputbreadcrumbscustom_meta_tagsverification_tagsrobots_managementsitemapllms_txtaeo |
| Request handling | Off | Redirects, a log of 404s, a link scanner and an integrity check. | redirectsmonitor_404link_scannerintegrity_checker |
| Visitor data | Off | Tracking and bot tracking, server-side page views, tag manager and consent, GA4 e-commerce, A/B tests, heatmaps, scroll maps, Web Vitals and surveys. | trackingbot_trackingserver_pageviewtag_managerconsentga4_ecommerceab_testingheatmapscrollmapweb_vitalssurveys |
| Integrations | Off | Umbraco Commerce, Umbraco Forms, members, the dictionary, webhooks, email alerts and Cloudflare. | commerceformsmembersdictionarywebhooksemail_alertscloudflare |
Where to switch features
An admin turns features on and off under Features in the CitationLab dashboard. Developers can do the same through the API with PUT features. Manager reads what is switched on through GET capabilities, which reports both what the installation can do and what is enabled. A call to a feature that is off answers 403 with the code feature_disabled.
4. API reference
The package serves a REST API from your own Umbraco site. This is the API Manager uses after pairing, and your developers can use it too. It is separate from Manager's own REST API, which is described under Resources → API.
Manager's REST API →Base URL
Every route sits under this path on your site's own domain:
https://your-site.example/umbraco/citationlab/v1/Authentication
- Send the key from pairing as a bearer token: Authorization: Bearer cl_um_… The key is cl_um_ followed by 40 hexadecimal characters.
- GET health is public and needs no key.
- The pairing routes (pair, pairing-status, disconnect) do not accept the key. They need a signed-in backoffice session.
Conventions
- Query parameters are snake_case: per_page, document_type_alias, parent_id, order_by, include_properties.
- Request and response bodies are JSON in camelCase.
- Lists are paged and return items, total, page, perPage and totalPages. per_page defaults to 100 and is capped at 500.
- Content reads and writes take an optional culture parameter. One culture can be published on its own with POST content/{id}/translations/{culture}/publish.
- A node's {id} is its GUID key or its numeric id.
Errors
Every error has the same shape: error is a readable message, code is one of the values below, and details is present when there is more to say.
| Code | HTTP | Meaning |
|---|---|---|
invalid_request | 400 | The request is malformed, a parameter has the wrong value, or a required body is missing. |
feature_disabled | 403 | The feature behind this route is switched off. details.feature names the switch. |
not_found | 404 | The node, media item or record does not exist. |
operation_failed | 500 | Umbraco could not complete the operation. The site's log has the details. |
unsupported | 501 | The site cannot do this, for example because a required Umbraco add-on is not installed. |
HTTP/1.1 403 Forbidden
{
"error": "Feature 'redirects' is disabled on this site",
"code": "feature_disabled",
"details": { "feature": "redirects" }
}Endpoints
The main routes in each group, relative to the base URL. Each group also lists further resources it contains.
Discovery
| Method | Path | What it does |
|---|---|---|
| GET | health | Is the package running? Public, no key. |
| GET | version | The installed package version. |
| GET | site-info | Site name, URL and Umbraco version. |
| GET | capabilities | What this installation can do and what is switched on. |
| GET | features | The current feature switches. |
| PUT | features | Turn features on or off. |
Content
| Method | Path | What it does |
|---|---|---|
| GET | content | Paged list. Filters: status (published, draft, trashed), document_type_alias, search, culture, parent_id, order_by, direction, include_properties. |
| POST | content | Create a node. |
| GET · PUT · DELETE | content/{id} | Read, update or delete one node. |
| GET | content/by-path | Find a node by its URL path. |
| GET | content/{id}/children | The node's children. |
| POST | content/{id}/publish | Publish. |
| POST | content/{id}/unpublish | Unpublish. |
| POST | content/{id}/move | Move under another parent. |
| POST | content/{id}/copy | Copy. |
| GET | content/{id}/preview-url | A preview link for unpublished changes. |
| GET | content/{id}/tags | The node's tags. |
Versions, scheduling and languages
| Method | Path | What it does |
|---|---|---|
| GET | content/{id}/versions | Version history. |
| GET | content/{id}/versions/{versionId} | One earlier version. |
| POST | content/{id}/versions/{versionId}/restore | Roll back to that version. |
| GET · PUT | content/{id}/schedule | Scheduled publish and unpublish dates. |
| GET | languages | The site's languages. |
| GET | content/{id}/translations | Which cultures a node exists in. |
| POST | content/{id}/translations/{culture}/publish | Publish one culture only. |
Bulk, search and document types
| Method | Path | What it does |
|---|---|---|
| POST | bulk/content/status | Publish or unpublish many nodes. |
| POST | bulk/content/update | Update many nodes. |
| POST | bulk/content/delete | Delete many nodes. |
| POST | bulk/seo-meta | Set SEO fields on many nodes. |
| GET | search | Search the site's own Examine index, in relevance order. |
| GET | search/indexes | The available Examine indexes. |
| GET | document-types | All document types. |
| GET | document-types/{alias} | One document type and its properties. |
SEO, structured data and AI files
| Method | Path | What it does |
|---|---|---|
| GET · PUT | seo-meta/{id} | Title, description, focus keyword, canonical and robots settings for a node. |
| GET · PUT | content/{id}/og | Open Graph and social card fields. |
| GET · PUT | llms | The site's llms.txt. |
| POST | llms/generate | Propose an llms.txt from the site's structure. It is returned, not saved. |
| GET · PUT | aeo | Settings for the AI-facing files, and the address each one is served at. |
Also in this group: meta-templates custom-meta-tags settings/verification-tags breadcrumbs term-meta seo-lock robots sitemap schema schema-graph entities citation-check ai-visibility
Redirects and 404s
| Method | Path | What it does |
|---|---|---|
| GET · POST | redirects | List or create redirects. |
| GET · PUT · DELETE | redirects/{id} | Read, change or remove one redirect. |
| POST | redirects/import | Import redirects. |
| GET | redirects/export | Export redirects. |
| GET | 404-log | Logged 404s, with routes to create redirects from them. |
Also in this group: redirects/settings 404-log/stats link-scan integrity
Media
| Method | Path | What it does |
|---|---|---|
| GET · POST | media | List or upload media. |
| GET · PUT · DELETE | media/{id} | Read, change or delete a media item. |
| POST | media/{id}/file | Replace the file. |
| GET | media/{id}/usage | Where the item is used. |
| POST | media/folder | Create a media folder. |
| GET | media-types | The site's media types. |
Also in this group: alt-text/scan alt-text/stats alt-text/bulk-update
Operations
| Method | Path | What it does |
|---|---|---|
| GET | audit-log | Every write made through the package, with the previous value. |
| POST | audit-log/{id}/restore | Put the previous value back. |
| GET · PUT | settings/webhook | Webhook receiver URL and secret. The secret is never returned. |
| POST | settings/webhook/test | Send a signed test delivery. |
Also in this group: site-health alerts cloudflare migration compat tracking bot-log pageview tag-manager consent ga4-ecommerce
Umbraco-specific
| Method | Path | What it does |
|---|---|---|
| GET | dictionary | Umbraco dictionary items. |
| PUT | dictionary/{key} | Change a dictionary item. |
| GET | members | Umbraco members. |
| GET | forms | Umbraco Forms, when it is installed. |
| GET | commerce/products | Umbraco Commerce products, when it is installed. |
| GET | commerce/orders | Umbraco Commerce orders. |
Also in this group: member-groups member-types commerce/customers commerce/categories
Umbraco Forms and Umbraco Commerce are read through a bridge that looks for them at runtime, so the package installs and runs on sites that do not have those licences. The routes then answer with unsupported.
Files served from the site root
With the matching page-output switches on, the package also answers these addresses on your domain:
/robots.txt/sitemap.xml/llms.txt/llms-full.txt/ai.json/.well-known/ai.json/ai-info
Examples
curl https://your-site.example/umbraco/citationlab/v1/capabilities \
-H "Authorization: Bearer cl_um_YOUR_API_KEY"curl "https://your-site.example/umbraco/citationlab/v1/content?page=2&per_page=50&status=published&culture=en-US" \
-H "Authorization: Bearer cl_um_YOUR_API_KEY"{
"items": [ … ],
"total": 412,
"page": 2,
"perPage": 50,
"totalPages": 9
}curl -X PUT "https://your-site.example/umbraco/citationlab/v1/seo-meta/NODE_KEY?culture=en-US" \
-H "Authorization: Bearer cl_um_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Plumbing services in Oslo",
"description": "Emergency and planned plumbing, 24/7.",
"canonical": "https://your-site.example/services/plumbing/",
"noindex": false
}'5. Webhooks
The package can send a signed notification to an HTTPS endpoint you choose whenever content or media changes: your own service, or an automation tool such as Zapier, Make or n8n.
- 1
Turn on the webhooks switch
The webhooks feature is off by default.
- 2
Set the receiver
PUT settings/webhook with a url (http or https) and a secret. GET settings/webhook shows the url and hasSecret, never the secret itself.
- 3
Send a test
POST settings/webhook/test delivers a signed test event so you can check the receiver before a real change.
Nothing is sent until the switch is on and both a url and a secret are set. Pairing already points the webhook at CitationLab Manager with its own secret, so turning the switch on is enough for Manager to see a published change within seconds. Set your own receiver and Manager leaves it in place.
curl -X PUT https://your-site.example/umbraco/citationlab/v1/settings/webhook \
-H "Authorization: Bearer cl_um_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://hooks.example.com/umbraco", "secret": "a-long-random-string" }'Events
| Event | Sent when |
|---|---|
content.saved | A content node is saved. |
content.published | A content node is published. |
content.unpublished | A content node is unpublished. |
content.trashed | A content node is moved to the recycle bin. |
content.deleted | A content node is deleted permanently. |
media.saved | A media item is saved. |
media.deleted | A media item is deleted. |
What a delivery looks like
A JSON POST with event, siteUrl, timestamp (Unix seconds) and data. Fields that are null are left out. X-CL-Event repeats the event name, and X-CL-Signature carries the signature.
POST /umbraco HTTP/1.1
Host: hooks.example.com
Content-Type: application/json; charset=utf-8
X-CL-Event: content.published
X-CL-Signature: 3f1c…e9a0
{
"event": "content.published",
"siteUrl": "https://your-site.example",
"timestamp": 1760000000,
"data": { "id": "NODE_KEY", … }
}Verify the signature
X-CL-Signature is the HMAC-SHA256 of the exact raw request body, keyed with your secret, as lowercase hex. Compute it over the raw bytes before you parse the JSON, and compare with a constant-time function. Re-serialising the parsed body changes the bytes and the check fails.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.CL_WEBHOOK_SECRET;
// Keep the raw bytes: the signature covers the body exactly as sent.
app.post("/umbraco", express.raw({ type: "application/json" }), (req, res) => {
const expected = crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
const received = req.get("X-CL-Signature") ?? "";
const valid =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) return res.sendStatus(401);
const payload = JSON.parse(req.body.toString("utf8"));
console.log(req.get("X-CL-Event"), payload.data);
res.sendStatus(204);
});using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
app.MapPost("/umbraco", async (HttpRequest request, IConfiguration config) =>
{
// Keep the raw bytes: the signature covers the body exactly as sent.
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer);
var body = buffer.ToArray();
var key = Encoding.UTF8.GetBytes(config["CitationLab:WebhookSecret"]!);
var expected = Convert.ToHexString(HMACSHA256.HashData(key, body)).ToLowerInvariant();
var received = request.Headers["X-CL-Signature"].ToString();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(received)))
return Results.Unauthorized();
using var payload = JsonDocument.Parse(body);
return Results.NoContent();
});Delivery and retries
- Deliveries are queued and sent outside the save. Saving in the backoffice never waits for your receiver.
- Any status below 400 counts as delivered.
- After a network error, a timeout, a 5xx, 408 or 429, the package tries twice more: after 1 second and after 3 seconds. Other 4xx answers are not retried.
- When all three attempts fail, the package gives up and writes a [citationlab] warning to the Umbraco log. Use the event as a signal to fetch the current state from the API, not as the only copy of the change.
Security and privacy
Pairing needs a backoffice login
Only an admin signed in to your backoffice can pair a site. A URL alone cannot connect anything.
Where the key lives
On the site, the key is stored in App_Data/CitationLab/apikey.json. Treat that file like a password and keep App_Data out of source control. Manager stores its copy encrypted.
Opt-in by default
Only reading is on after install. Page output, request handling, visitor data and integrations each need a deliberate switch.
No raw IP addresses
When tracking is on, the package never stores a visitor's raw IP address.
Every write is logged
The package keeps its own audit log of every change made through it, with the previous value, and a change can be rolled back. Changes made from Manager are logged in Manager as well.
Secrets stay put
The webhook secret is write-only through the API: reads return hasSecret, never the value.
Troubleshooting
| What you see | What to do |
|---|---|
| Pairing token not found | Copy the token again from Manager, or generate a new one. If you have set CitationLab:ManagerUrl, the token must come from that Manager. |
| Pairing token already used | Tokens work once. Generate a new one. |
| Pairing token expired | Tokens are valid for 15 minutes. Generate a new one and pair straight away. |
| Could not reach CitationLab Manager: … | The web server cannot make outbound HTTPS calls to manager.citationlab.ai. Allow it in the firewall or proxy and try again. |
| No CitationLab dashboard in the Content section | Your user is not in the admin group, or the site runs Umbraco 14 or later, which the dashboard does not support yet. |
| 403 with code feature_disabled | The route belongs to a feature that is off. details.feature names it. Turn it on under Features in the dashboard. |
| Manager shows a localhost or internal address | The site was paired from that address. Pair again from the backoffice on the public hostname; it replaces the stored URL. |
| Webhooks never arrive | Check that the webhooks switch is on and that both url and secret are set, then send POST settings/webhook/test. Failed deliveries are logged with [citationlab]. |
Frequently asked questions
No. After install the package only reads content so Manager can sync it. Nothing is written into your pages, no requests are redirected and no visitor data is collected until an admin turns those features on.
Umbraco 13 LTS on .NET 8. Umbraco 14 and later are not supported yet, because the backoffice extension model changed and the dashboard has to be rewritten. The API side is not affected by that change.
15 minutes, and it can be used once. If it expires or has been used, generate a new token in Manager under Integrations → Umbraco.
Yes. The API runs on your own site and accepts the key the site received when it was paired, sent as a bearer token. The key is stored on the server in App_Data/CitationLab/apikey.json.
Yes. Every content read and write takes an optional culture, and you can publish one culture on its own.
Disconnecting in the dashboard removes the key stored on the site. Removing the connection in Manager deletes the connection and Manager's cached content; the Umbraco site itself is not changed.
Yes. Point them at any HTTPS endpoint you control, such as your own service, Zapier, Make or n8n, and verify the X-CL-Signature header with your secret.
Running Umbraco for a client?
Tell us about the site and what you want Manager to handle, and we will help you get it paired and configured.
Was this page helpful?

