Security guarantee / Invariant execution guard Enforced in code | 0 write mutations | opsprey.watch

The read-only pledge

You are handing us a key to infrastructure you are responsible for. Here is exactly what we do with it, and the code that makes the promise hard to break by accident.


Three commitments

03 / invariants
01 // ProtocolGET only

We never write to your n8n.

Every request to a monitored instance goes through one HTTP client. That client refuses any method other than GET before a byte leaves our process. A unit test that tries POST, PUT, PATCH and DELETE must fail loudly on every commit.

One HTTP client, no exceptions
02 // Data boundaryMetadata

We store metadata only.

Execution status, timestamps, the failing node name and the error message text. Never the items flowing through your workflows. When we fetch an execution's detail to read the error, we extract those two strings and discard the rest.

No workflow payload persisted
03 // CustodyAt rest

Your key is encrypted at rest.

With a key that lives only in our server's environment, decrypted only in the poller's memory, and shown to anyone, including us, as the last four characters.

Shown as the last 4 only

Scopes we ask for

Minimal API footprint

Required API key scopes

›workflow:list›workflow:read›execution:read›execution:list

A key with only these cannot change anything even if we wanted it to.

The part most tools do not tell you. Choosing scopes in the n8n interface is one of n8n's licensed features (feat:apiKeyScopes). Without that licence the key you create there carries the full access of the account that created it — not read access, every access. That is true whatever we do at our end, and it is the single best reason to be sceptical of anyone asking you for an n8n key.

You can still get a genuinely restricted key on any plan, because n8n enforces scopes whichever way the key was made — we verified that on Community 2.35.7 and 2.37.10, where a key missing workflow:list gets a 403. It has to be created through the API rather than the interface:

on your own n8n, logged in as ownercreates a read-only key
POST /rest/api-keys
{"label": "opsprey", "scopes": ["workflow:list", "workflow:read", "execution:read", "execution:list"], "expiresAt": null}

Set an expiry you are comfortable with either way, and ask any monitoring vendor — us included — how they store what you hand them.

The guard, verbatim

Release blocker

This is the top of src/opsprey/client.py in the running service:

src/opsprey/client.py Read-only session wrapper
"""Minimal read-only n8n public API client.

Verified live against n8n 2.35.7 on 2026-08-29. Uses only GET endpoints — read-only
access to a customer's instance is the promise this module exists to keep.

This module is the single choke point for all HTTP to a customer's n8n.
``ReadOnlySession`` refuses any method other than GET before a byte leaves the
process. Nothing that writes to a customer instance belongs in this file.
"""
from __future__ import annotations

import requests


class ReadOnlyViolation(RuntimeError):
    """Raised when code attempts a non-GET request to a customer instance."""


class ReadOnlySession(requests.Session):
    """requests.Session that hard-blocks every method except GET."""

    ALLOWED_METHODS = frozenset({"GET"})

    def request(self, method: str, url: str, *args, **kwargs):  # type: ignore[override]
        if method.upper() not in self.ALLOWED_METHODS:
            raise ReadOnlyViolation(
                f"Opsprey is read-only: refused {method.upper()} {url}"
            )
        kwargs["allow_redirects"] = False  # Session.get() defaults this to True; force off — never leave the customer host
        return super().request(method, url, *args, **kwargs)
All outbound poller traffic routes through this wrapper.

Tests that must pass on every commit

test_non_get_methods_are_refused test_convenience_write_methods_are_refused test_get_passes_through_to_transport test_execution_payload_items_are_never_persisted

Verify it yourself

Connect an instance and watch what we do with the key. We test it with a single GET and never write to it.

Connect an instance