taler-docs

Documentation for GNU Taler components, APIs and protocols
Log | Files | Refs | README | LICENSE

wordpress-paivana-manual.rst (23924B)


      1 ..
      2   This file is part of GNU TALER.
      3 
      4   Copyright (C) 2026 Taler Systems SA
      5 
      6   TALER is free software; you can redistribute it and/or modify it under the
      7   terms of the GNU Affero General Public License as published by the Free Software
      8   Foundation; either version 2.1, or (at your option) any later version.
      9 
     10   TALER is distributed in the hope that it will be useful, but WITHOUT ANY
     11   WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
     12   A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more details.
     13 
     14   You should have received a copy of the GNU Affero General Public License along with
     15   TALER; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
     16 
     17   @author Christian Grothoff
     18 
     19 .. _wordpress-paivana:
     20 
     21 
     22 Wordpress integration
     23 =====================
     24 
     25 .. note::
     26 
     27   This chapter is still early work in progress.
     28 
     29 This chapter documents the installation and operation of the
     30 ``taler-turnstile`` Wordpress plugin.  The plugin gates individual Wordpress
     31 posts (and any other selected public post type) behind a GNU Taler paywall:
     32 visitors who have not yet paid are shown a teaser plus a payment widget, and
     33 once their wallet has paid the associated merchant template the full post
     34 content is unlocked.
     35 
     36 Like its :ref:`Drupal counterpart <drupal-paivana>`, the plugin is built
     37 around the same Paivana-style flow as :ref:`paivana-httpd <Paivana-httpd>`: a
     38 static, cacheable paywall page references a *payment template* in the merchant
     39 backend, no order is created when a visitor merely loads a paywalled article,
     40 and no PHP session is started for unauthenticated traffic.  This keeps bot
     41 traffic from polluting the merchant backend and lets Wordpress' page cache (or
     42 any reverse-proxy cache in front of it) serve the paywall page to anonymous
     43 visitors without per-request work.
     44 
     45 
     46 Architecture overview
     47 ---------------------
     48 
     49 A Wordpress site using Turnstile combines three independent
     50 components:
     51 
     52 1. **The Wordpress site.**  An ordinary Wordpress 6.3+ installation
     53    serving one or more public post types (``post``, ``page``, custom
     54    post types, ...).  The ``taler-turnstile`` plugin adds a
     55    *Price Category* meta field (stored as the post meta key
     56    ``_taler_price_category``) to the post types you select, and
     57    hooks ``the_content`` and ``template_redirect`` to intercept
     58    rendering of posts that carry a non-empty value.
     59 2. **A GNU Taler merchant backend** (``taler-merchant-httpd``).
     60    The backend stores one *payment template* per price category
     61    defined in Wordpress, manages orders, talks to one or more Taler
     62    exchanges, and ultimately reports back whether a given order
     63    has been paid.  See the
     64    :ref:`Taler Merchant Backend Operator Manual
     65    <taler-merchant-backend-operator-manual>` for full details.
     66 3. **The visitor's GNU Taler wallet.**  The wallet scans the QR
     67    code shown on the paywall page (or follows the
     68    ``taler://pay-template/…`` link), pays the merchant template
     69    and triggers the unlock.
     70 
     71 Turnstile itself stores no per-visitor state in the Wordpress database; once
     72 the merchant backend confirms a payment, Turnstile mints a short-lived HMAC
     73 cookie (``taler_turnstile_paivana``, keyed by Wordpress' built-in
     74 ``wp_salt('auth')``) that the browser presents on subsequent requests to the
     75 same fulfillment URL.  Clearing cookies forgets paid access, but GNU Taler
     76 wallet's repurchase detection will restore the cookie if the user attempts to
     77 pay for the same article again.
     78 
     79   .. note::
     80 
     81     This feature likely does not interact well with expiration
     82     periods for article purchases. This is a bug that will
     83     require further work to fully address. See #11443.
     84 
     85 
     86 Installation
     87 ------------
     88 
     89 Requirements
     90 ^^^^^^^^^^^^
     91 
     92 - Wordpress 6.3 or later
     93 - PHP 8.0 or later
     94 - **Pretty permalinks must be enabled.**  With the default plain
     95   permalinks (``?p=123``) every post is served from path ``/``,
     96   which means the path-scoped access cookie cannot be isolated
     97   per article; the plugin therefore refuses to paywall posts in
     98   that configuration and shows an admin notice on the settings
     99   pages until pretty permalinks are turned on under
    100   *Settings → Permalinks*.
    101 - A reachable GNU Taler merchant backend supporting the v29 (or
    102   newer) ``paivana`` template type and the public
    103   ``/sessions/$SESSION_ID`` endpoint
    104 - An access token for the merchant instance with the
    105   ``templates-write`` and ``orders-read`` permissions
    106 
    107 
    108 Obtaining the plugin
    109 ^^^^^^^^^^^^^^^^^^^^
    110 
    111 The plugin sources are available from the GNU Taler Git repository as well as
    112 Wordpress' own plugin directory.  Download the latest release and extract it
    113 into your Wordpress installation's ``wp-content/plugins/`` directory so that
    114 the resulting layout is:
    115 
    116 .. code-block:: shell-session
    117 
    118    $ ls wp-content/plugins/taler-turnstile/
    119    admin/      assets/      includes/
    120    COPYING     README.md    taler-turnstile.php
    121 
    122 There is no build step and no Composer package: the plugin is plain PHP and a
    123 handful of JavaScript files.  The QR code library ``assets/js/qrcode.min.js``
    124 is vendored from `davidshimjs/qrcodejs
    125 <https://github.com/davidshimjs/qrcodejs>`__ and must be present for the QR
    126 code to render; see ``assets/js/QRCODE-README.md`` in the plugin directory for
    127 the download instructions if you are building from source.
    128 
    129 
    130 Activating the plugin
    131 ^^^^^^^^^^^^^^^^^^^^^
    132 
    133 Once the directory is in place, activate the plugin from
    134 *Plugins → Installed Plugins* and press *Activate* on the
    135 *GNU Taler Turnstile* row:
    136 
    137 .. image:: screenshots/wordpress-turnstile-plugin-activate.png
    138 
    139 Activation registers the plugin's options with their defaults
    140 (by default, the ``post`` post type is enabled and the backend
    141 URL/token are left blank) and adds three new admin menu entries:
    142 
    143 - *Settings → Taler Turnstile* (main settings),
    144 - *Settings → Taler Subscriptions* (subscription purchase prices),
    145 - *Taler Prices* (top-level menu, price-category management).
    146 
    147 Deactivating the plugin removes the meta box and stops the
    148 paywall from rendering, but it does currently **not** delete templates
    149 already published to the merchant backend; remove those manually
    150 if desired (see :ref:`template`).
    151 
    152 
    153 Configuring the basics
    154 ----------------------
    155 
    156 The main settings page lives at
    157 ``wp-admin/options-general.php?page=taler-turnstile-settings``
    158 and is also reachable through *Settings → Taler Turnstile*.  It
    159 collects the four pieces of information Turnstile needs to talk
    160 to the merchant backend and to know which types of content to gate:
    161 
    162 .. image:: screenshots/wordpress-turnstile-settings-form.png
    163 
    164 The fields are:
    165 
    166 - **Enabled Post Types** — a list of checkboxes derived from
    167   every *public* post type registered on the site (the built-in
    168   ``post`` and ``page``, plus any custom post types).  Each
    169   ticked post type gains the *Taler Turnstile Price Category*
    170   sidebar meta box on its editor screen, and gets it removed
    171   again when unticked.  Disabling every post type triggers a
    172   one-shot cleanup that drops the ``_taler_price_category`` meta
    173   from every post on the site, so re-enabling a post type later
    174   starts from a clean slate.
    175 - **Payment Backend URL** — the HTTP(S) base URL of the merchant
    176   backend.  Instance-specific URLs ending in ``/instances/$ID/``
    177   are accepted; the URL **must end with a trailing slash**.  When
    178   you save the form with a non-empty value, Turnstile contacts
    179   the backend's ``/config`` endpoint to verify reachability and
    180   refuses to save the value if the backend does not respond as a
    181   ``taler-merchant`` instance.
    182 - **Access Token** — the Bearer token used for every call to the
    183   backend.  It **must begin with** ``secret-token:`` (following
    184   RFC 8959, see :ref:`Access control
    185   <taler-merchant-backend-operator-manual>` in the merchant
    186   manual).  When both URL and token are present, the form
    187   performs a live authentication check against the backend and
    188   surfaces the HTTP status as an admin notice if the access
    189   token is wrong (401/403), the instance does not exist (404),
    190   or the backend is otherwise unreachable.
    191 - **Disable Turnstile on Error** (fail-open toggle, off by
    192   default) — when the merchant backend cannot be reached, this
    193   controls whether visitors see the unpaywalled article
    194   (checked) or an error message in place of the payment widget
    195   (unchecked).  The settings form warns you with an admin
    196   notice if you leave the toggle off while the backend URL or
    197   access token is missing, so a misconfiguration cannot
    198   silently break the live site.
    199 
    200 The first time you save a working backend URL and access token,
    201 Turnstile pushes a ``paivana``-style template to the merchant
    202 backend for every price category that already exists.  Each
    203 template carries the ID ``turnstile-{category_id}``.  You do not
    204 need to create those templates by hand, and you do not need to
    205 visit the merchant backend's SPA to manage them — Turnstile keeps
    206 them in sync as you edit the categories (see
    207 :ref:`wordpress-paivana-price-categories` below).  Saving a new
    208 backend URL or access token also republishes every existing
    209 price-category template, on the assumption that the new instance
    210 does not have them yet.
    211 
    212 Before continuing, make sure that the merchant backend instance
    213 itself is ready to take payments:
    214 
    215 - The instance has at least one configured
    216   :ref:`bank account <instance-bank-account>`.
    217 - Any legitimization required by the chosen payment service
    218   provider has been completed.
    219 
    220 
    221 .. _wordpress-paivana-subscription-prices:
    222 
    223 Configuring subscription prices
    224 -------------------------------
    225 
    226 .. note::
    227 
    228   The term *subscription* is slightly misleading, as it is not
    229   auto-renewing. We are in the process of renaming this to *passes* which is
    230   clearer. However, the current plugin uses subscription and thus so does this
    231   manual.
    232 
    233 Turnstile supports the GNU Taler subscription model, in which a visitor buys a
    234 *subscription token* once and the token is then consumed to unlock individual
    235 articles for some validity period.  The catalogue of available *token
    236 families* is configured on the merchant backend (see the merchant manual
    237 section on :ref:`templates <template>` and the wider Taler concept of
    238 subscription tokens); Turnstile reads the catalogue live from
    239 ``private/tokenfamilies`` and asks you to set the *purchase price* per token
    240 family per currency.
    241 
    242 Navigate to *Settings → Taler Subscriptions* (URL
    243 ``wp-admin/admin.php?page=taler-subscription-prices``).  The page refuses to
    244 render until the backend is configured and reachable — it needs the live
    245 token-family list and a complete currency inventory from ``/config``.  When
    246 the backend is ready, the page lists one *postbox* block per token family
    247 advertised by the backend, with one numeric input per supported currency:
    248 
    249 .. image:: screenshots/wordpress-turnstile-subscription-list.png
    250 
    251 Leaving a field empty disables the *Buy subscription in
    252 {currency}* payment choice for that combination — the
    253 subscription will then not be offered for sale in that currency
    254 on any article.
    255 
    256 If the merchant backend has no token families configured, the page shows a
    257 warning and offers nothing to edit.  In that case you can still operate
    258 Turnstile in pure pay-per-article mode; simply skip this configuration step.
    259 
    260 Whenever this page is saved, Turnstile republishes **every** price-category
    261 template against the backend so the new subscription prices appear in the
    262 wallet's *Buy subscription* choices immediately.
    263 
    264 .. note::
    265 
    266   GNU Taler token families offer more flexible payment options,
    267   not just subscriptions. However, only subscriptions are
    268   supported by Turnstile at this time.
    269 
    270 
    271 .. _wordpress-paivana-price-categories:
    272 
    273 Defining price categories
    274 -------------------------
    275 
    276 A *price category* is a named set of payment options that can be attached to
    277 posts.  Categories are managed under the top-level *Taler Prices* menu in the
    278 admin sidebar (URL ``wp-admin/admin.php?page=taler-price-categories``).  The
    279 list page shows the existing categories together with their descriptions and
    280 offers *Edit*, *Delete* and *Add New* actions:
    281 
    282 .. image:: screenshots/wordpress-turnstile-price-category-list.png
    283 
    284 
    285 Creating or editing a price category
    286 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    287 
    288 Click *Add New* (or *Edit* on an existing row) to reach the
    289 price-category form.  The form has two sections:
    290 
    291 - **Name** — human-readable label for the category (e.g.
    292   *Standard article*, *Long-form feature*).  Shown to editors
    293   when they pick a category on a post-edit screen and used as
    294   the merchant template's summary text.
    295 - **Description** — free text shown to editors and used as the
    296   template description on the merchant backend if non-empty.
    297 
    298 Below the meta fields, the form has one *Prices* postbox per known token
    299 family, plus a special block labelled *No reduction* that holds the price for
    300 non-subscribers.  Each block contains one numeric input per supported
    301 currency:
    302 
    303 .. image:: screenshots/wordpress-turnstile-price-category-edit.png
    304 
    305 Pricing semantics:
    306 
    307 - A value in the *No reduction* block sets the **per-article
    308   price for visitors without a subscription** in that currency.
    309   Leave it empty to make the article available **only** to
    310   subscribers in that currency.
    311 - A value in a subscription block sets the **discounted price
    312   for holders of that subscription token** in that currency.  A
    313   value of exactly ``0`` marks the article as *fully covered* by
    314   that subscription — subscribers see it for free.  An empty
    315   field means "this subscription does not unlock articles of
    316   this category in this currency", so a holder of such a
    317   subscription has to pay the full per-article price.
    318 
    319 At least one price (across all blocks and currencies) must be set; otherwise
    320 the category yields no valid payment choices and the matching template cannot
    321 be published.
    322 
    323 Saving the form publishes the category as a ``paivana``-style template to the
    324 merchant backend (the v1 ``choices`` array is built from the price grid; see
    325 :ts:type:`TemplateContractPaivana` in the :ref:`Merchant Backend HTTP API
    326 <merchant-api>`).  If the local save succeeds but the backend push fails
    327 (network error, 401, ...), the admin notice on the next page surfaces the
    328 error.
    329 
    330 .. note::
    331 
    332   In this case, the local price in Wordpress and the merchant
    333   template may have drifted out of sync. Visitors would still
    334   pay the price in the merchant backend, so this error should
    335   always be investigated.
    336 
    337 Editing a category or changing prices automatically refreshes the matching
    338 merchant template via ``PATCH``.  Deleting a category removes its template via
    339 ``DELETE``; if the delete on the backend fails, you are warned to clean it up
    340 manually.
    341 
    342 
    343 Gating individual posts
    344 -----------------------
    345 
    346 Once a post type is enabled in the basic settings (see above)
    347 and at least one price category exists, every post of that
    348 post type gains a new *Taler Turnstile Price Category* sidebar
    349 meta box on its editor screen (shown in the bottom right corner):
    350 
    351 .. image:: screenshots/wordpress-turnstile-post-meta-box.png
    352 
    353 To paywall a post:
    354 
    355 1. Edit or create a post of an enabled post type.
    356 2. Pick a category in the *Price Category* drop-down on the
    357    sidebar meta box.
    358 3. *Update* / *Publish* the post.
    359 
    360 Leaving the drop-down on *-- None --* leaves the post freely accessible — the
    361 paywall logic only fires for posts that reference a category.
    362 
    363 If you want to remove the paywall from a post, edit it and change the meta box
    364 back to *-- None --*.  If you want to lift the paywall from an entire post
    365 type, untick it in the *Enabled Post Types* list on the settings page; the
    366 meta box and the post meta are then removed from every post of that post type.
    367 Re-enabling the post type later starts from a clean slate, forcing you to set
    368 the price categories again for all posts of that type.
    369 
    370 
    371 The visitor experience
    372 ----------------------
    373 
    374 Anonymous visitor without a cookie
    375 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    376 
    377 For a post that references a price category, an anonymous
    378 visitor without a valid ``taler_turnstile_paivana`` cookie sees:
    379 
    380 - The post rendered as its *excerpt* (the explicit ``excerpt``
    381   field, or the first 55 words of the body if none is set).
    382 - A *Payment Required* block immediately below the excerpt with
    383   a QR code (encoding the ``taler://pay-template/…`` URI for the
    384   matching merchant template), a *Scan with your GNU Taler
    385   wallet* hint, and — when the browser advertises support for
    386   the ``taler`` URI scheme — an *Open GNU Taler payment page*
    387   button.
    388 - A pricing summary showing the per-article price (or "Not sold
    389   individually" if only subscriptions unlock the category) and
    390   the list of subscriptions that accept this category.
    391 - A *Waiting for payment…* status line that updates once the
    392   wallet picks up the order.
    393 
    394 .. image:: screenshots/wordpress-turnstile-paywall-page.png
    395 
    396 The page is identical for every visitor without the cookie, so Wordpress' page
    397 cache (and any reverse-proxy cache in front of it) can serve it without
    398 per-request work.  The response sets ``Vary: Cookie`` so a returning paying
    399 visitor's path-scoped access cookie still busts the cached paywall, and the
    400 ``taler://pay-template/…`` URI is also advertised as a ``Paivana:`` HTTP
    401 header for non-JavaScript Taler clients.
    402 
    403 Behind the scenes, the JavaScript on the page does **not** create an order in
    404 the merchant backend.  It generates a fresh nonce, derives a session ID from
    405 it (the ``paivana_id``), renders the QR code, and long-polls the public
    406 ``sessions/{paivana_id}`` endpoint on the merchant backend waiting for the
    407 wallet to pick the template up and pay.  No order, no PHP session and no
    408 database write happens until the visitor's wallet actually engages with the
    409 template.
    410 
    411 
    412 Paying
    413 ^^^^^^
    414 
    415 The visitor scans the QR code with a `GNU Taler wallet
    416 <https://docs.taler.net/wallet/>`__ (or clicks the *Open GNU Taler payment
    417 page* button when running a browser with a wallet extension).  The wallet
    418 shows the available payment choices — for a typical category these are:
    419 
    420 - *Pay in {currency}* — the per-article price for non-subscribers.
    421 - *Pay in {currency} with subscription* — only offered to
    422   visitors whose wallet holds a matching, unspent subscription
    423   token (in which case it usually costs ``{currency}:0``).
    424 - *Buy subscription in {currency}* — pays the per-article price
    425   plus the subscription's purchase price (from
    426   :ref:`wordpress-paivana-subscription-prices` above) and yields
    427   a fresh subscription token in the visitor's wallet.
    428 
    429 The visitor picks an option, the wallet pays, and Wordpress'
    430 JavaScript proceeds to the confirmation step.
    431 
    432 
    433 Confirmation and unlock
    434 ^^^^^^^^^^^^^^^^^^^^^^^
    435 
    436 Once the merchant backend reports the order as paid, the JavaScript POSTs
    437 ``{order_id, nonce, cur_time, website}`` to the plugin's REST endpoint at
    438 ``/wp-json/taler-turnstile/v1/paivana``.  The Wordpress-side controller
    439 re-derives the canonical ``paivana_id``, resolves the fulfillment URL back to
    440 a local post via ``url_to_postid()``, fetches the order from the merchant
    441 backend and **verifies that the paid contract's ``fulfillment_url`` matches
    442 the requesting page and that the paid amount is one of the amounts the price
    443 category accepts**.  Only then does it mint the ``taler_turnstile_paivana``
    444 cookie and instruct the browser to reload the article.
    445 
    446 The cookie is keyed on Wordpress' built-in ``wp_salt('auth')``, bound to the
    447 visitor's ``REMOTE_ADDR`` and ``Path``-scoped to the specific fulfillment URL,
    448 marked ``HttpOnly``, ``SameSite=Lax``, and ``Secure`` when the page was served
    449 over HTTPS.  It is valid for 24 hours (the ``ORDER_VALIDITY_SECONDS`` constant
    450 in ``Taler_Merchant_API``; this will follow the merchant-supplied
    451 ``pay_deadline``/``max_pickup_time`` in the future).  The cookie covers
    452 exactly one fulfillment URL — clearing cookies forgets paid access, and access
    453 to a different paywalled article requires a fresh payment (or a valid
    454 subscription).
    455 
    456 The REST confirmation endpoint is unauthenticated by design (the payer has no
    457 Wordpress account) and is therefore rate-limited per-IP to at most 30 requests
    458 per minute, comfortably above legitimate use but capping the abuse of an
    459 endpoint that triggers a merchant-backend round-trip per call.
    460 
    461 .. note::
    462 
    463   ``ORDER_VALIDITY_SECONDS`` should probably be made
    464   configurable in the future, as should the restriction of the
    465   client to a particular IP address, as that may or may not be
    466   desired. See #11444.
    467 
    468 
    469 Subscriptions in action
    470 ^^^^^^^^^^^^^^^^^^^^^^^
    471 
    472 When a paid contract bought a subscription token, Turnstile also stores the
    473 token family slug and the token's expiration time in the visitor's PHP session
    474 (the session is started only *after* the successful payment, so the cacheable
    475 pre-payment path is unaffected).  Subsequent requests to other posts whose
    476 price category is fully covered by the same subscription short-circuit the
    477 paywall entirely: the visitor never sees the *Payment Required* block again
    478 until the token expires.
    479 
    480 Visitors without a PHP-session cookie still see the paywall page (so
    481 anonymous, cache-friendly traffic stays cacheable); the subscriber check is
    482 gated on the session cookie already being present so that loading a paywalled
    483 post never creates a session on its own.
    484 
    485 
    486 Operational notes
    487 -----------------
    488 
    489 Caching
    490 ^^^^^^^
    491 
    492 The plugin emits cache-control headers tuned for the two paths it produces:
    493 
    494 - The static paywall response carries ``Vary: Cookie``, so a
    495   shared cache (page cache plugin, reverse proxy, CDN) can store
    496   one copy for all anonymous visitors while still busting it for
    497   returning paying visitors whose path-scoped
    498   ``taler_turnstile_paivana`` cookie now accompanies their
    499   request.
    500 - The post-payment response (the article, served only to the
    501   visitor who paid) is marked ``Cache-Control: private,
    502   no-store`` and triggers the ``DONOTCACHEPAGE`` define plus
    503   ``nocache_headers()`` so it never lands in a shared cache.
    504 - The REST confirmation endpoint under ``/wp-json/`` is not
    505   page-cached by Wordpress, and its response is explicitly
    506   marked ``no-store``.
    507 
    508 A reverse-proxy cache in front of Wordpress must therefore honour the ``Vary:
    509 Cookie`` header to avoid handing one paying visitor's full-content response to
    510 another visitor.
    511 
    512 
    513 Logging
    514 ^^^^^^^
    515 
    516 The plugin writes to PHP's ``error_log`` (i.e. the destination your
    517 ``php.ini`` or web server configures for ``error_log``).  Notable events that
    518 are logged include unreachable backends, malformed responses from the merchant
    519 backend, paid orders whose contract does not match the fulfillment URL or
    520 whose paid amount falls outside the price category, and template
    521 create/update/delete failures.  When debugging an integration problem, tailing
    522 the PHP error log while reproducing the issue is usually the fastest way to
    523 identify the culprit.
    524 
    525 
    526 Permissions
    527 ^^^^^^^^^^^
    528 
    529 All admin pages — the basic settings, the subscription prices and the
    530 price-category CRUD — are gated on Wordpress' ``manage_options`` capability,
    531 which corresponds to the *Administrator* role.  Editors who only need to
    532 assign a price category to a post do not need *manage_options*: the sidebar
    533 meta box is rendered for any user who can edit the post in question
    534 (``edit_post`` capability check on the meta field).
    535 
    536 
    537 Hooks and filters
    538 ^^^^^^^^^^^^^^^^^
    539 
    540 The plugin exposes a small number of WordPress filters so a theme or
    541 site-specific plugin can adapt Turnstile to local conventions without forking
    542 it:
    543 
    544 - ``taler_turnstile_enabled_post_types`` — alter the list of
    545   post types subject to the paywall transformation.
    546 - ``taler_turnstile_payment_choices`` — modify the payment
    547   ``choices`` array before it is sent to the merchant backend.
    548 - ``taler_turnstile_currencies`` — add to or modify the
    549   list of currencies discovered from the backend's ``/config``.
    550 
    551 These hooks are intentionally narrow; deeper customisation (for example,
    552 overriding the paywall HTML) requires patching
    553 ``Taler_Content_Filter::render_paywall()`` directly.