Sign in

Integration modules: the Sendl pattern

Updated Jul 3, 2026

When Devani embeds an external service on a page (forms, interactive images, and future modules), the integration always has the same five parts. The Sendl forms integration is the canonical reference — read its files side by side with this page.

The five parts

  1. A settings key<service>_api_key, seeded in JsonStore::defaultSettings() (so it back-fills existing installs), overridable via DEVANI_<SERVICE>_API_KEY env for managed hosts, listed in the write-only secret-keys set and the MCP allowedIntegrationSettings.
  2. An admin-only proxy — e.g. devani/sendl-forms.php. Session-authenticated, reads the key server-side, calls the service's list API, returns {configured, items[], error}. The browser never sees the key. Degrade gracefully: no key → configured:false; upstream failure → error string, and the editor falls back to manual entry.
  3. An editor block — registered in the block picker (page-editor.js) with a special id. Configured: fetch the proxy, list items to pick. Not configured: show the onboarding card (what the service is + a link to /devani/?settings#<service>_api_key + the service's dashboard). Always: a manual URL/slug fallback that works with zero setup. The configured flag comes from a data-<service>-configured attribute PHP puts on the editor panel.
  4. An MCP tool pairget_<service>_config (status + guidance + listable items) and insert_<service>_<thing> (validates the target URL against a strict pattern, snapshots, appends/prepends the block). Add the insert tool to writeTools.
  5. Sanitizer-safe markup — identical between the editor block and the MCP tool:
    <div class="devani-module devani-module-sendl"
         data-devani-module="sendl"
         data-sendl-form="https://dash.sendl.io/f/contact">
      <iframe class="devani-sendl-form" src="..." loading="lazy"
              style="width:100%;border:0;min-height:900px;"></iframe>
    </div>
    data-devani-module is on the sanitizer's allowlist; service-specific data uses its own namespace; classes avoid the stripped devani-inline*/devani-ed* prefixes. loading="lazy" keeps third-party iframes from costing LCP.

Checklist for a new module

  • Settings key seeded + env override + both allowlists
  • Proxy endpoint, session-gated, key never exposed
  • Editor block with onboarding card + manual fallback
  • MCP config + insert tools, insert in writeTools
  • Markup mirrored editor ↔ MCP, lazy-loaded, sanitizer-tested (add assertions to tests/sanitizer-test.php)
  • Verify the target embeds cleanly in an iframe (no X-Frame-Options / frame-ancestors block upstream)
Was this helpful?

Related articles

Powered by Subido