matthiasott/webmention

Webmention Plugin for Craft CMS

Maintainers

Package info

github.com/matthiasott/webmention

Documentation

Type:craft-plugin

pkg:composer/matthiasott/webmention

Transparency log

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 46

Open Issues: 1

v1.5.0 2026-08-16 14:39 UTC

README

Webmention icon in the shape of a W

Webmention for Craft CMS

This plugin provides a Webmention endpoint for Craft CMS and allows for sending Webmentions to other sites.

Table of Contents

Requirements

This plugin requires Craft CMS 5.6.10 or later and PHP 8.2 or later.

Installation

Plugin Store

You can install this plugin from Craft's in-app Plugin Store.

Go to the Plugin Store in your project's Control Panel and search for "Webmention", then click on the "Install" button in the sidebar.

Note

If you're updating from Webmention v0.3, follow these instructions as well.

Composer

Or install via Composer:

composer require matthiasott/webmention
php craft plugin/install webmention

Queue runner

Webmentions are processed asynchronously via Craft's queue. Make sure you have a queue runner in place — otherwise queued jobs will only run when someone visits the Control Panel.

The simplest option is a cron entry that calls Craft's queue runner every minute:

* * * * * /usr/bin/php /path/to/craft queue/run

For higher-traffic sites, run php craft queue/listen as a long-running daemon (e.g. via systemd or supervisord). See the Craft docs on queue runners for details.

Configuring the plugin

Webmention settings can be accessed from SettingsWebmention.

Screenshot showing the Webmention plugin settings

The following options are available:

Webmention Endpoint Route (Slug)

Set the URL slug of your Webmention endpoint. Defaults to webmention, but you can insert anything that makes sense to you.

Maximum Length of Webmention Text

Set the maximum character count for summaries, comments and text excerpts from posts. Default: 420.

Parse Brid.gy Webmentions

Toggle if you want the plugin to parse Brid.gy Webmentions. See Bridgy and social integrations.

Failure Retention (Days)

Number of days that failed Webmention records are kept before they can be purged by the cleanup command. Default: 30. See Cleaning up old failure records.

Threaded Display

When enabled, replies to other Webmentions are displayed as nested threads in the default template. When disabled, all Webmentions are shown in a flat list. Default: enabled. See Threading.

Rate limit per hour

Cap on the number of Webmention submissions accepted per remote IP, per hour. Default: 100. Set to 0 to disable. Senders that exceed the limit receive a 429 Too Many Requests response.

Submissions whose source host matches an entry in Trusted source hosts bypass this limit.

Trusted source hosts

A list of source hostnames (and their subdomains) that are exempted from both the per-IP rate limit and the private/reserved-IP check that protects against SSRF. Default: ['brid.gy'], which automatically covers fed.brid.gy and bsky.brid.gy.

Add additional hosts here if you operate a high-volume Webmention sender of your own (a homelab Mastodon, an intranet Micropub server, another aggregator service) whose traffic should not be throttled and which may live behind a private IP.

This setting applies to incoming Webmentions only. Outbound sending never contacts a private or reserved address — see Outbound request protection.

Failure backoff threshold

After a (source, target) pair has been recorded as a failure this many times, further submissions for that pair are no longer queued or fetched until the failure record is purged via the cleanup command. Default: 5. Set to 0 to disable.

This prevents an attacker (or a misbehaving sender) from forcing repeated outbound fetches against the same broken pair. The failure row's attempt counter still increments so repeated abuse stays visible in the Failed Webmentions view.

Avatar Location

The plugin saves user photos (avatars) for incoming Webmentions to a Craft Asset volume. Storing them locally keeps your visitors' IP addresses from leaking to remote image hosts, and improves front-end performance.

You can set the volume and subfolder path where avatars will be stored. The volume needs to expose public URLs (so the saved avatars can be served by getUrl() in templates).

Entry Types

Select all entry types for which you want to send Webmentions. When new entry types are added, they are set to "send Webmentions" by default. See Sending Webmentions.

Permissions

The plugin registers two user permissions:

Permission Grants
View webmentions and the failure log The Webmentions section in the Control Panel, including the Failed Webmentions view.
Manage webmentions Retrying, dismissing, and deleting Webmentions and failure records.

Admins have both, as do all users on Craft Solo. On Team and Pro, grant them under SettingsUsers. Users without the view permission don't see the Webmentions nav item at all.

Front-end output is unaffected — templates render Webmentions for all visitors regardless of permissions.

Receiving Webmentions

Endpoint discovery

In order to receive Webmentions, the Webmention endpoint of your site needs to be discoverable by the server sending the Webmention. There are two ways to advertise it — using both is recommended, since some senders look for one or the other:

Add this line to the <head> section of your main layout template:

<link rel="webmention" href="{{ craft.webmention.endpointUrl }}" />

And/or set an HTTP Link header by adding this line to your main layout template:

{% header "Link: <" ~ craft.webmention.endpointUrl ~ ">; rel=\"webmention\"" %}

The endpoint template

Visiting your endpoint route in a browser shows a form with source and target fields, so people can send you a Webmention by hand. The template extends your site's standard layout. Run the following command to copy an example endpoint template into your project's templates/ directory:

php craft webmention/example-template

You can then adjust the template to your needs.

Webmention types

When the plugin processes an incoming Webmention, it classifies the interaction into one of the following types based on the source's microformats data and (if Brid.gy parsing is enabled) the source URL pattern:

Type Meaning
mention A generic mention or link
comment A comment or reply
like A like / favorite
repost A repost / boost / share
rsvp An RSVP to an event

You can filter by type when fetching Webmentions in templates — see Fetching Webmentions in templates.

HTTP responses

Because Webmention processing happens asynchronously, the plugin's direct HTTP responses are mostly status codes only:

Status Meaning
202 Accepted The Webmention has been queued for processing (the spec-compliant happy path).
400 Bad Request Invalid source or target URL, or target doesn't belong to this site.
429 Too Many Requests The sender's IP has exceeded the per-hour rate limit.

A 202 may also be returned when the submission is deduplicated against a recent identical submission, or when the (source, target) pair has hit the failure backoff threshold — in those cases the plugin silently no-ops rather than re-queueing.

Manual receive via CLI

For testing purposes, or if you want to add Webmentions yourself manually, you can use the webmention/receive CLI command, which processes the Webmention for a given source and target:

php craft webmention/receive <source> <target>

Displaying Webmentions

The default template

To output all Webmentions for the current request URL using the plugin's bundled template, use the following helper:

{{ craft.webmention.showWebmentions() }}

The default template renders comments / mentions, likes, and reposts as separate microformats2-compatible sections. It uses standard mf2 class names (h-cite, h-card, p-author, u-url, u-photo, p-name, e-content, dt-published) so the rendered output can be styled with any mf2-aware stylesheet.

Fetching Webmentions in templates

If you want full control over the HTML output, fetch all Webmentions for the current URL:

{% for webmention in craft.webmention.getWebmentions() %}
  <li>
    <a href="{{ craft.webmention.safeUrl(webmention.source) ?: '#' }}">{{ webmention.authorName }}</a>:
    {{ webmention.text|purify|raw }}
  </li>
{% endfor %}

To fetch all Webmentions for an element, call getWebmentions() on the element:

{% for webmention in entry.getWebmentions() %}
  …
{% endfor %}

And if you want to fetch only Webmentions of a certain type, like comments, likes, or reposts, call getWebmentionsByType() on the element:

{% for webmention in element.getWebmentionsByType('like') %}
  …
{% endfor %}

Important

When you write a custom template, always pass URLs through craft.webmention.safeUrl(...) before using them as href values, and pass author-controlled HTML through |purify|raw. The bundled template does this automatically; skipping it can re-introduce XSS via javascript: URLs or unsanitized HTML.

Threading

When a Webmention is itself a reply to another Webmention (typical for Bridgy-routed Bluesky and Mastodon conversations), the plugin links it to its parent via the parentId property. The default template renders these as nested threads when the Threaded Display setting is enabled.

To work with threads in custom templates, use the threaded helpers, which return top-level Webmentions with their children populated recursively:

{% set thread = craft.webmention.getThreadedWebmentionsForElement(entry) %}
{% for webmention in thread %}
  <article>
    <p>{{ webmention.authorName }}: {{ webmention.text|purify|raw }}</p>
    {% if webmention.children|length %}
      <ol>
        {% for reply in webmention.children %}
          <li>{{ reply.authorName }}: {{ reply.text|purify|raw }}</li>
        {% endfor %}
      </ol>
    {% endif %}
  </article>
{% endfor %}

Each Webmention also exposes:

  • webmention.getParentWebmention() — the Webmention this is a reply to, or null
  • webmention.getChildWebmentions() — direct replies to this Webmention
  • webmention.getInReplyToUrl() — the first in-reply-to URL from the source's mf2 data

Eager-loading

The plugin supports eager-loading elements with the following values passed into the with param:

  • webmentions — all Webmentions
  • webmentions:<type> — Webmentions of a specific type. Supported types: mention, comment, like, repost, rsvp.
{% set entries = craft.entries()
  .section('blog')
  .with(['webmentions:like', 'webmentions:comment'])
  .all() %}

With that in place, calling element.getWebmentions() or element.getWebmentionsByType() will return the eager-loaded Webmentions, rather than querying for them for each individual element.

If all you want to do is output the total, you can set the with path's criteria to {count: true}:

{% set entries = craft.entries()
  .section('blog')
  .with([
    ['webmentions:like', {count: true}],
    ['webmentions:comment', {count: true}],
  ])
  .all() %}

Alternatively, you can use the element.getTotalWebmentions() and getTotalWebmentionsByType() methods to output the total. Both methods support eager-loading as well.

Webmention form

You can output a form in your entry template that lets people directly send you the URL of a response:

{{ craft.webmention.webmentionForm() }}

By default the form targets the current page; pass an explicit URL as the first argument to target a different page:

{{ craft.webmention.webmentionForm('https://example.com/some-post') }}

Customizing the markup

The bundled templates (webmentions.twig and webmention-form.twig) live inside the plugin and are rendered in Craft's CP template mode, so they aren't overridable via the standard site-template fallback path.

For custom markup, don't call showWebmentions() or webmentionForm(). Build your own template using the data helpers from Fetching Webmentions in templates and Threading, using the bundled templates as a starting point.

When you write your own template, remember:

  • Guard every href="…" with craft.webmention.safeUrl(...) (which returns the URL when it's a valid http(s): URL, or null otherwise).
  • Pass author-controlled HTML through |purify|raw.
  • Pass author-controlled plain text through Twig's default autoescape (i.e. just {{ value }}).

Sending Webmentions

Once installed, your Craft site will automatically send Webmentions to other sites. On every save of a published entry, the plugin scans the complete entry for any occurrences of URLs and then sends Webmentions to the corresponding Webmention endpoints.

Sending for certain entry types only

By default, Webmentions are sent for all entry types but you can also restrict this to certain entry types. Please make sure to go to the Entry Types setting and select for which entry types Webmentions should be sent.

Switching Webmentions on/off for individual entries

To control sending per entry, add a "Webmention Switch" field to the field layout of an Entry Type.

Screenshot showing the creation of a new field

The field's Default Value setting controls whether new entries start with the switch on or off.

Screenshot of the new field in the control panel

The switch overrides the Entry Types setting. So you can disable Webmentions for an Entry Type and still send them for individual entries.

Maintenance

Failed Webmentions view

When an incoming Webmention can't be processed (the source can't be fetched, no backlink is found, the source contains no h-entry, etc.), the failure is recorded in the webmention_failures table and surfaced in the Control Panel under Webmentions → Failed.

Each row shows the source, target, error message, attempt count, and timestamps. From this view you can:

  • Retry an individual failure — re-queues it for processing.
  • Dismiss an individual failure — removes the record without retrying.
  • Retry All — re-queues every failure currently shown.
  • Dismiss All — removes all failure records.

Duplicate failures for the same source+target pair are consolidated into a single record with an incrementing attempt count.

A source counts as linking back only if it links to the exact target URL. The comparison ignores a trailing slash, the fragment, and common tracking parameters (utm_*, ref, fbclid, …), but paths are case-sensitive — a link to /Post/1 will not verify against /post/1.

Cleaning up old failure records

Failure records older than the Failure Retention setting can be pruned with:

php craft webmention/cleanup/failures

Schedule this as a daily cron job to keep the failures table tidy:

0 3 * * * /usr/bin/php /path/to/craft webmention/cleanup/failures

Logging

The plugin logs to its own Monolog channel under the webmention category, writing to storage/logs/webmention*.log. Start here when debugging a stuck or failing Webmention: both incoming validation results and outbound fetch attempts (avatar downloads, source verifications, Bluesky API calls) are captured.

CLI reference

All console commands provided by the plugin:

Command Purpose
webmention/receive <source> <target> Manually process a Webmention for a given source and target. Useful for testing or backfilling.
webmention/cleanup/failures Purge failure records older than the Failure Retention setting.
webmention/update [--webmention-id=…] [--source=…] [--target=…] Re-process one or more existing Webmentions (re-fetches the source, re-parses, re-saves). Useful after upgrading or for repairing records with stale data.
webmention/update-avatars Re-save avatar assets for all existing Webmentions. Used when migrating from v0.3, or after changing the Avatar Location setting.
webmention/example-template [--folder-name=…] [--overwrite] Copy the example endpoint template into your project's templates/ folder.
webmention/webmentions/refetch-bad-authors [--like=…] [--target=…] [--limit=…] [--dry-run] Re-queue existing Webmentions whose stored author name looks wrong, so the current parser can overwrite it. A repair tool for records saved before v1.5.0 — see Upgrade notes.
resave/webmentions [--update-search-index] Standard Craft resave command for Webmention elements. Re-saves elements without re-fetching the source.

Bridgy and social integrations

You can use Bridgy for receiving Webmentions for posts, comments, reposts, likes, etc. from Mastodon, Bluesky, GitHub, Reddit, Instagram, Flickr, and more. This plugin will understand the Webmention and set the type accordingly.

If you don't use Bridgy, you can deactivate the parsing in the Parse Brid.gy Webmentions setting.

How interaction types are detected

When Bridgy parsing is enabled, the plugin inspects the source URL pattern from Bridgy to determine the interaction type:

  • /post/mention
  • /comment/comment
  • /like/like
  • /repost/repost
  • /rsvp/rsvp

It also detects the originating social network from the source URL path (/facebook/, /flickr/, /github/, /instagram/, /mastodon/, /bluesky/, /reddit/) and stores it on the Webmention's host field. See the Bridgy docs on source URLs for the full URL format.

Bluesky author fallback

Bridgy Fed converts Bluesky posts to HTML for delivery but strips all h-card data, which means standard mf2 parsing yields no author. When the plugin detects this situation — the source is bsky.brid.gy, or the canonical entry URL is bsky.app — it makes a fallback call to the public AT Protocol API at public.api.bsky.app to fetch the author's display name, profile URL, and avatar.

The call happens automatically as part of the receive flow and is best-effort: if the API is unreachable or the DID can't be resolved, the Webmention is saved with whatever data is available.

Note

The Bluesky author fallback is the only outbound network call the plugin makes to a service other than the Webmention sender itself. The call sends only the author's DID (a public identifier) — no information from your site is included.

Security

The plugin processes anonymous POST requests from the open internet, fetches remote URLs via Guzzle, and parses untrusted HTML. The following protections are in place:

HTML sanitization

To prevent Cross Site Scripting (XSS) attacks, the HTML of the source is purified with HTMLPurifier, both when it is saved and again when it is rendered. URI schemes inside the comment text are restricted to http(s): only. SVG avatars are sanitized via enshrined/svg-sanitize before being saved to the asset volume, stripping <script> tags, event handlers, and <foreignObject> constructs.

URL validation

All URLs accepted by the endpoint (and stored on Webmention records) are passed through a safeUrl() helper that:

  • Rejects schemes other than http(s):
  • Rejects URLs containing whitespace, embedded credentials (user:pass@…), or illegal host characters
  • Rejects URLs longer than 2,048 characters
  • Rejects URLs without a host

The same helper is exposed to Twig as craft.webmention.safeUrl(...) so that custom templates can apply the same guard before using a stored URL as a href value.

Rate limiting and abuse protection

Three layered protections bound the cost of abusive submissions:

  • Per-IP rate limit (default 100/hour) — see Rate limit per hour.
  • Pair deduplication (5-minute window) — identical (source, target) submissions within the window are no-op'd.
  • Failure backoff — see Failure backoff threshold.

Trusted high-volume senders bypass the rate limit via Trusted source hosts.

Outbound request protection

Every remote request the plugin makes — source pages, avatar downloads, endpoint discovery, and outbound Webmention delivery — goes through a guard that:

  • Resolves the hostname via DNS (both A and AAAA records) and refuses any IP in a private or reserved range (127.0.0.0/8, 10.0.0.0/8, 192.168.0.0/16, 169.254.0.0/16, etc.), preventing Server-Side Request Forgery (SSRF) into internal networks.
  • Pins the connection to the address it just validated, so a DNS-rebinding sender can't return a public address to the check and a private one to the connection. Hostname-based TLS verification is unaffected.
  • Follows redirects manually, repeating both steps at every hop and refusing non-http(s) targets, so a public URL can't redirect into an internal one.
  • Caps response bodies at 5 MB via a streaming size limit, preventing memory exhaustion from oversized responses.

Sources listed in Trusted source hosts bypass the IP check when the plugin fetches from them, for legitimate self-hosted senders on private networks. There is no equivalent bypass for sending: outbound delivery to a non-public endpoint always fails.

Reporting issues

If you discover a security issue, please email mail@matthiasott.com directly rather than opening a public GitHub issue. See the CHANGELOG for prior security releases.

Privacy

What's stored

Receiving a Webmention means storing data about a third-party author. For each accepted Webmention the plugin stores:

  • Author name (as parsed from the h-entry / h-card)
  • Author URL (validated as http(s):)
  • Author photo (downloaded and stored locally as a Craft asset to avoid leaking visitor IPs to remote image hosts)
  • The author-supplied text (purified)
  • The source URL
  • The full mf2 properties array

For failed Webmentions, the source and target URLs and an error message are stored in the webmention_failures table. Server filesystem paths are redacted from error messages and stack traces before storage.

Retention and deletion

  • Webmention elements can be deleted from the CP individually or in bulk, like any other Craft element. Their associated avatar assets are not automatically deleted (Craft uses a SET NULL foreign key on the avatar relation) — clean those up separately if needed.
  • Failure records are pruned by the webmention/cleanup/failures command after Failure Retention (Days). Schedule the command via cron — see Cleaning up old failure records.

If you have any webmentions which contain double-encoded HTML entities (from before v1.3.0), you can update them via the "Update" action in the UI, or with the following command:

php craft webmention/update --webmention-id=123

Twig reference

A short index of the most-used Twig helpers exposed by the plugin:

Helper Returns
craft.webmention.endpointUrl The full URL for your Webmention endpoint.
craft.webmention.showWebmentions(url?) Rendered HTML for all Webmentions for the given (or current) URL.
craft.webmention.webmentionForm(url?) Rendered HTML for a Webmention submission form.
craft.webmention.getWebmentions(url?) An array of Webmentions for the given (or current) URL.
craft.webmention.getThreadedWebmentions(url?) The same, organized as a threaded tree.
craft.webmention.getWebmentionsForElement(element) An array of Webmentions for a given element.
craft.webmention.getWebmentionsForElementByType(element, type) Same, filtered by type.
craft.webmention.getThreadedWebmentionsForElement(element) Threaded tree of Webmentions for an element.
craft.webmention.getWebmentionById(id) A specific Webmention by ID.
craft.webmention.getFailureCount() Total count of failure records.
craft.webmention.safeUrl(url) The URL if it's a valid http(s): URL, or null otherwise. Use as href guard in custom templates.
element.getWebmentions() An array of Webmentions for the element.
element.getWebmentionsByType(type) Same, filtered by type.
element.getTotalWebmentions() Count of Webmentions for the element.
element.getTotalWebmentionsByType(type) Same, filtered by type.
element.getThreadedWebmentions() Threaded tree of Webmentions for the element.

Upgrade notes

Updating to v1.5.0

Three changes may need action after upgrading:

  1. Permissions. The Control Panel section now requires the new permissions. Admins are unaffected, but non-admin user groups need them granted explicitly or the Webmentions nav item disappears for those users.

  2. Sending to private hosts. Outbound delivery now refuses endpoints on private or reserved IPs. If you send Webmentions to a receiver on an internal network, that delivery will start failing.

  3. Wrong author names. Before v1.5.0, a source page carrying several h-cards (a Mastodon thread, for example) could stamp an unrelated person's name and avatar onto a reply. To repair existing records, preview the affected rows and then re-fetch them:

    php craft webmention/webmentions/refetch-bad-authors --dry-run
    php craft webmention/webmentions/refetch-bad-authors

    The default --like=@% matches names overwritten with an @-handle, which is the common case. Pass --target=… to limit the repair to a single post.

Updating from v0.3

To update from Webmention 0.3 on Craft 2, do the following after upgrading Craft CMS:

  1. Follow the installation instructions.

  2. Go to SettingsWebmention and select the volume that avatars are stored in, from the Avatar Location setting.

  3. Run the following CLI command to update your existing webmentions' avatar relations:

    php craft webmention/update-avatars
  4. Run the following CLI command to update search indexes for existing webmentions:

    php craft resave/webmentions --update-search-index
  5. Update your templates based on the following changes:

    Old New
    webmention.author_name webmention.authorName
    webmention.author_url webmention.authorUrl
    webmention.author_photo webmention.avatar.getUrl()
    webmention.url webmention.hEntryUrl
    webmention.site webmention.host
    craft.webmention.getAllWebmentionsForEntry(craft.request.url) craft.webmention.getWebmentions()
    craft.webmention.showWebmentions(craft.request.url) craft.webmention.showWebmentions()
    craft.webmention.webmentionForm(craft.request.url) craft.webmention.webmentionForm()

Note

If you have any webmentions which contain double-encoded HTML entities, you can update them via the "Update" action in the UI, or with the following command:

php craft webmention/update --webmention-id=123

Changelog

For changes between minor and patch versions, see CHANGELOG.md.

Roadmap

  • Add a more GDPR-friendly "data economy mode" that collects webmentions from Bridgy and other social media sources without saving names and avatars but still allows for showing the amount of likes, reposts, etc.

Thank You!

Thanks to everyone who helped setting this up: – Jason Garber (@jgarber) for his webmention client plugin and the kind permission to reuse parts of the code when implementing the sending functionality.

  • Aaron Parecki (@aaronpk) for support and feedback – and also for the great work he does related to Webmention.
  • Bastian Allgeier (@bastianallgeier) for allowing me to get highly inspired by his Kirby Webmentions Plugin
  • Tom Arnold (@webrocker) for relentlessly sending test Webmentions. ;) Also for feedback on Webmention sending settings.
  • Jeremy Keith (@adactio) for the feedback and also for giving the initial spark.
  • Everyone at the IndieWebCamps Düsseldorf and Berlin 2016 and in the IndieWeb Community!
  • Brandon Kelly for basically rewriting the plugin to add support for Craft 5 and a lot more 🔥🎉

License

Code released under the MIT license.

Authors

Matthias Ott mail@matthiasott.com https://matthiasott.com

Brandon Kelly brandon@pixelandtonic.com https://brandonkelly.io