Manage External SaaS Resources with Pulumi Dynamic Providers

Not every resource you depend on has a first-class Pulumi provider — feature flags, DNS records at a niche registrar, a SaaS project, or a monitoring dashboard often live behind a plain REST API. This guide, part of the dynamic providers and custom resources topic under Pulumi patterns and provider management, shows how to wrap such an API in a typed Python dynamic provider so the external resource participates in pulumi up, state, and previews like any other.

Context

When a resource lives outside your state file, it drifts silently: someone edits it in a web console, and nothing in your infrastructure code notices. A dynamic provider brings that resource under the same lifecycle as your cloud resources — create, read, update, delete — so its desired shape is version-controlled and every change shows up in a preview diff.

Dynamic provider CRUD Dynamic provider CRUD: create then read/diff then update then delete create POST read/diff GET update PATCH delete DELETE
Pulumi maps each lifecycle event onto an HTTP verb against the SaaS API.

The cost is that you own the CRUD logic. Pulumi calls your create, diff, update, and delete methods; you translate those into REST calls and return an id plus the outputs Pulumi should record. Getting the diff and delete semantics right is what separates a toy from something you can run in production.

SaaS APIs are not small cloud APIs, and the differences are exactly where a naive provider breaks. A cloud control plane usually gives you an opaque, immutable ARN or resource name; a SaaS platform frequently gives you a numeric id and a human-editable key, and lets an operator rename the key in the UI. A cloud API is usually strongly consistent for read-after-write within a region; a SaaS API often serves reads from a replica, so a GET issued a hundred milliseconds after a POST returns 404. Cloud APIs enforce quotas per account; SaaS APIs enforce a per-token request rate and answer 429 with a Retry-After header the moment a stack creates twenty flags in parallel. And a cloud DELETE normally destroys; a SaaS DELETE frequently archives, leaving the key reserved so re-creating the same resource returns 409.

Every one of those behaviours has to be absorbed by the provider, because Pulumi gives you no retry, no consistency window, and no rename tracking for free. The engine's contract is simple and unforgiving: whatever you return from create is the identity forever, whatever you return from diff decides whether the resource is updated or replaced, and whatever exception escapes a method aborts the deployment with the resource left in whatever state the SaaS actually reached.

Prerequisites

Prerequisites Prerequisites: layered from Python down to SDK. Python Pulumi SDK
Prerequisites: the building blocks this section assembles.
  • Python 3.9+ with pulumi>=3.0 and requests>=2.31 pinned in your project
  • An API token for the SaaS platform exported as an environment variable (never hard-coded)
  • A resource with a stable id returned on creation — the id is how Pulumi finds it again
  • Write scope on the SaaS project or workspace the resources belong to, and read scope on the audit log so you can attribute out-of-band edits
  • Documented rate limits for the token you will use; a provider that ignores them will fail intermittently under parallel creation

Verify your interpreter and SDK before writing any provider code:

# CLI: confirm the Pulumi Python SDK is importable
python -c "import pulumi, requests; print(pulumi.__version__)"

Then probe the API by hand once. Knowing the exact shape of the create response — which field is the id, whether the body echoes fields you did not send, whether Retry-After is present — saves an afternoon of guessing inside diff:

# CLI: inspect the real create response before writing the provider
curl -sS -X POST https://api.example-flags.com/v1/flags \
  -H "Authorization: Bearer $FLAGS_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"key":"probe-only","on":false,"project":"checkout"}' | python -m json.tool

Implementation

A dynamic provider is a class implementing pulumi.dynamic.ResourceProvider. Each method receives and returns plain dictionaries; Pulumi handles serialisation into state. Keep the HTTP client thin and pass the token through resource inputs marked secret.

1. Pin the resource identity and the tenant scope

Before any HTTP code, decide which field becomes the Pulumi resource id. This choice cannot be revised later without hand-editing state, because the id is written into the checkpoint on the first pulumi up and every subsequent read, update, and delete is addressed by it.

Choosing the resource identity Choosing the resource identity: choose among 3 options. What does the SaaS return oncreate? uuid Immutable id — storeit as id_ slug Mutable key — forcereplacement onchange none Wrong fit — use aninvoke, not aresource
The id you return from create is permanent, so pick the field the SaaS never rewrites.

Model the inputs as a frozen dataclass so the scope travels with the resource. A SaaS resource almost always lives inside a project or workspace, and forgetting to include that scope in the inputs means two stacks pointed at different projects will look identical to diff:

# providers/flag_args.py — imported by __main__.py
# CLI: pulumi up
from dataclasses import dataclass

@dataclass(frozen=True)
class FeatureFlagArgs:
    key: str          # human-editable slug; renaming it upstream is a rename, not a new flag
    on: bool
    project: str      # tenant scope — must be part of state or diffs compare across projects
    description: str = ""
    # State implication: every field here is written to the checkpoint as an input and is
    # what `diff` receives as `new`; omit a field and drift in it is permanently invisible.

2. Implement create, read, and diff against the flag API

Provider call sequence Provider call sequence: pulumi up → Provider → SaaS API. pulumi up Provider SaaS API create POST id+url register
On create, Pulumi calls the provider, which POSTs to the API and registers the returned id.

The provider below builds its HTTP session inside each method. That is not stylistic: Pulumi pickles the provider instance into state, and a requests.Session holds an _thread.lock, so keeping one as an attribute fails the deployment before any request is sent.

# providers/flag_provider.py — a dynamic provider for a feature-flag SaaS resource
# CLI: pulumi up   (Pulumi invokes these methods; do not call them yourself)
from typing import Any
import requests
import pulumi
from pulumi.dynamic import (
    ResourceProvider, CreateResult, ReadResult, DiffResult, UpdateResult, Resource,
)

API = "https://api.example-flags.com/v1/flags"

class _FlagProvider(ResourceProvider):
    def _session(self, token: str) -> requests.Session:
        from requests.adapters import HTTPAdapter
        from urllib3.util.retry import Retry
        # Provider note: built per call — a Session holds an _thread.lock and cannot be pickled.
        retry = Retry(total=5, backoff_factor=0.5, status_forcelist=(429, 502, 503),
                      allowed_methods=frozenset({"GET", "POST", "PATCH", "DELETE"}),
                      respect_retry_after_header=True)
        s = requests.Session()
        s.mount("https://", HTTPAdapter(max_retries=retry))
        s.headers.update({"Authorization": f"Bearer {token}"})
        return s

    def create(self, props: dict[str, Any]) -> CreateResult:
        s = self._session(props["token"])
        r = s.post(API, json={"key": props["key"], "on": props["on"],
                              "project": props["project"],
                              "description": props["description"]}, timeout=15)
        r.raise_for_status()
        flag = r.json()
        # Provider note: the returned id must be stable for the life of the resource.
        return CreateResult(id_=flag["id"], outs={**props, "url": flag["url"],
                                                  "etag": r.headers.get("ETag", "")})

    def read(self, id_: str, props: dict[str, Any]) -> ReadResult:
        s = self._session(props["token"])
        r = s.get(f"{API}/{id_}", timeout=15)
        if r.status_code == 404:
            # State implication: returning an empty id_ tells Pulumi the resource is gone,
            # so `pulumi refresh` removes it from the checkpoint instead of leaving a ghost.
            return ReadResult(id_=None, outs={})
        r.raise_for_status()
        body = r.json()
        return ReadResult(id_=id_, outs={**props, "key": body["key"], "on": body["on"],
                                         "description": body.get("description", ""),
                                         "url": body["url"],
                                         "etag": r.headers.get("ETag", "")})

    def diff(self, id_: str, old: dict[str, Any], new: dict[str, Any]) -> DiffResult:
        def norm(d: dict[str, Any]) -> dict[str, Any]:
            # Provider note: the API lower-cases keys and trims descriptions on write,
            # so compare normalised values or every preview reports a spurious change.
            return {"key": str(d.get("key", "")).lower(),
                    "on": bool(d.get("on")),
                    "project": d.get("project"),
                    "description": str(d.get("description", "")).strip()}
        a, b = norm(old), norm(new)
        changed = [k for k in a if a[k] != b[k]]
        # State implication: 'key' and 'project' are immutable upstream, so either forces
        # a replacement — delete-then-create — rather than an in-place update.
        replaces = [k for k in ("key", "project") if k in changed]
        return DiffResult(changes=bool(changed), replaces=replaces,
                          stables=["project"], delete_before_replace=True)

Note the token field: it is an input, so it lands in state. Source it from pulumi.Config().require_secret("flagsApiToken") and Pulumi encrypts it in the checkpoint, exactly as described in securing Pulumi secrets.

3. Update and delete with SaaS archive semantics

Update is where SaaS platforms differ most from cloud APIs. Many expose optimistic concurrency through ETag/If-Match; sending the tag you last read turns a lost update into a 412 Precondition Failed you can surface instead of silently clobbering an operator's change. Delete is where teardown either works or strands the stack:

# providers/flag_provider.py (continued)
# CLI: pulumi up  /  pulumi destroy
class _FlagProvider(_FlagProvider):  # shown separately for readability
    def update(self, id_: str, old: dict[str, Any], new: dict[str, Any]) -> UpdateResult:
        s = self._session(new["token"])
        headers = {"If-Match": old["etag"]} if old.get("etag") else {}
        r = s.patch(f"{API}/{id_}", json={"on": new["on"],
                                          "description": new["description"]},
                    headers=headers, timeout=15)
        if r.status_code == 412:
            raise pulumi.RunError(
                f"flag {id_} changed outside Pulumi since the last apply; "
                "run `pulumi refresh` before re-applying")
        r.raise_for_status()
        return UpdateResult(outs={**new, "url": r.json()["url"],
                                  "etag": r.headers.get("ETag", "")})

    def delete(self, id_: str, props: dict[str, Any]) -> None:
        s = self._session(props["token"])
        r = s.delete(f"{API}/{id_}", timeout=15)
        if r.status_code == 404:
            # State implication: the flag was already archived upstream. Returning cleanly
            # lets `pulumi destroy` finish instead of stranding the stack mid-teardown.
            return
        r.raise_for_status()

class FeatureFlag(Resource):
    url: pulumi.Output[str]
    def __init__(self, name: str, args: FeatureFlagArgs, token: pulumi.Input[str],
                 opts: pulumi.ResourceOptions | None = None) -> None:
        super().__init__(_FlagProvider(), name,
                         {**vars(args), "token": token, "url": None, "etag": None}, opts)

Verification

Run a preview first: it should show the resource as a create with no errors, and a second pulumi up with no code changes should report zero updates — proof your diff is stable.

Verification Verification: Test → Program → Mock/Cloud. Test Program Mock/Cloud invoke declare resolve assert
Verification: the test drives the program and asserts on resolved values.
# CLI: preview, apply, then confirm the second preview is a no-op
pulumi preview --diff
pulumi up --yes
pulumi preview   # expect: no changes

If the second preview wants to update every time, your diff is comparing fields the API normalises (e.g. it lower-cased the key). Normalise both sides before comparing.

Three further checks are worth wiring into the loop before the provider reaches a shared stack. First, prove drift detection actually works: toggle the flag in the SaaS console, then run pulumi refresh and confirm the pending change appears. Second, prove teardown is clean by archiving the flag in the console and then running pulumi destroy — the 404 path in delete is the one nobody tests until it strands a stack. Third, assert the pickling constraint in a unit test so a future refactor that caches a session on the instance fails in CI rather than in production:

# tests/test_flag_provider.py
# CLI: python -m pytest tests/test_flag_provider.py -q
import pickle
from providers.flag_provider import _FlagProvider

def test_provider_is_picklable() -> None:
    # Provider note: Pulumi serialises the provider into state; this must never regress.
    assert pickle.loads(pickle.dumps(_FlagProvider())) is not None

def test_case_change_is_not_a_diff() -> None:
    p = _FlagProvider()
    old = {"key": "Checkout-V2", "on": True, "project": "web", "description": " x "}
    new = {"key": "checkout-v2", "on": True, "project": "web", "description": "x"}
    assert p.diff("f-1", old, new).changes is False
# CLI: prove refresh reconciles an out-of-band toggle
pulumi refresh --diff --yes
pulumi stack export --file state.json   # inspect the stored outs, incl. the encrypted token

Gotchas & Edge Cases

Gotchas & Edge Cases Gotchas & Edge Cases: Where it breaks with 4 facets. Where it breaks pulumi.Output. watch this boundary DELETE watch this boundary Edge Cases watch this boundary Output watch this boundary
Gotchas & Edge Cases: the boundaries where things break and what to check.

Secrets leak into state. Inputs passed to a dynamic resource are serialised into state. Mark the token secret with pulumi.Output.secret or configure it through pulumi config set --secret so it is encrypted, as covered in securing Pulumi secrets.

Delete must be idempotent. If the resource was already removed upstream, a DELETE returning 404 should not fail the destroy. Swallow the not-found case.

Provider code is pickled. Pulumi serialises the provider class; keep module-level imports at the top and avoid closures over unpicklable objects. Caching a requests.Session on the instance produces TypeError: cannot pickle '_thread.lock' object before a single request leaves the machine.

Read-after-write races look like a broken create. A SaaS API serving reads from a replica answers the GET that follows a POST with 404, which surfaces as requests.exceptions.HTTPError: 404 Client Error: Not Found for url: https://api.example-flags.com/v1/flags/f-8812. Do not retry blindly inside create — return the id you already have from the POST body and let read reconcile on the next refresh.

Parallel creation trips the rate limit. Pulumi creates independent resources concurrently, so declaring thirty flags means thirty simultaneous POST calls. Without the Retry adapter above you get requests.exceptions.HTTPError: 429 Client Error: Too Many Requests for url: https://api.example-flags.com/v1/flags, and the stack half-applies. If the platform's limit is strict, chain resources with depends_on to serialise them deliberately rather than relying on backoff alone.

Archived keys are still reserved. After a destroy, re-creating the same key can fail with 409 Client Error: Conflict for url: https://api.example-flags.com/v1/flags because the SaaS archived rather than deleted it. Decide explicitly whether the provider should adopt the archived resource — usually it should not, because adopting silently makes Pulumi responsible for a resource it did not create.

A 15-second timeout is not always enough. A requests.exceptions.ReadTimeout: HTTPSConnectionPool(host='api.example-flags.com', port=443): Read timed out. (read timeout=15) raised inside create leaves the flag created upstream but unknown to state. Prefer an idempotency key header if the platform supports one, so the retried POST returns the original resource instead of a duplicate.

Operational Notes

The provider is code that runs on every engineer's laptop and in every pipeline, so treat its failure modes as a small operations problem rather than a coding detail. The table below is the decision matrix worth encoding once and reusing across every SaaS resource you wrap.

SaaS failure signals and provider responses SaaS failure signals and provider responses: comparison across What it means, Provider response. HTTP signal What it means Provider response 429 Rate limit hit Sleep on Retry-After, retry 409 Key already taken Fail loudly, do not adopt 404 on delete Already archived Return None, treat as done 412 ETag mismatch Re-read, then re-diff
Each SaaS status code maps to one deliberate decision inside a lifecycle method.

Token rotation deserves its own habit. Because the token is a resource input, rotating it changes new on the next run — and if you left it out of the ignored set, diff reports a change on every resource that carries it. Either exclude the token from the comparison in diff (as the norm helper above does by omission) or pass it through pulumi.ResourceOptions(ignore_changes=["token"]). Rotating a token should be an event nobody notices in a preview.

Blast radius matters more here than with a cloud provider, because a SaaS platform rarely offers point-in-time recovery. A wrong replaces entry turns an innocuous rename into delete-then-create, which for a feature flag means the flag briefly disappears and every client falls back to its default. Before shipping a change to diff, run pulumi preview --diff against a scratch stack and read the plan for the word replace; if it appears where you expected an update, fix the diff, not the plan.

Finally, keep the provider's blast radius observable. Log the SaaS request id from the response headers into the resource outputs, so when an audit shows a flag changed at 03:14 you can match it to a specific pulumi up. Pair that with the mocked-request tests in unit testing Pulumi programs with mocks and the provider stops being the least trusted part of the stack.

FAQ

Do dynamic providers support import?

Yes, but you must implement the read method so pulumi import can hydrate state from the live resource. Without read, import and refresh cannot reconcile external changes.

How is this different from a ComponentResource?

A ComponentResource groups existing resources; a dynamic provider defines a brand-new resource type with its own CRUD against an external system.

Can I unit test the provider?

Yes — the CRUD methods are plain functions taking dicts. Mock requests and assert the payloads, exactly as in unit testing Pulumi programs with mocks.

What happens if the SaaS API is down during pulumi up?

The exception propagates and the deployment fails partway, leaving already-created resources in state and the rest unattempted. That is the correct behaviour — re-running converges — provided your create is safe to retry, which is why an idempotency key or a read-before-create guard is worth the effort on any resource whose key must be unique.

Should the SaaS token be a resource input or read from the environment?

Make it an input sourced from pulumi.Config().require_secret(...). Reading os.environ inside a lifecycle method works on a laptop but breaks the moment the provider runs somewhere the variable is not exported, and it hides the credential from pulumi stack export, where an encrypted secret is actually easier to audit.

How do I rename a flag without destroying it?

You cannot, unless the platform exposes a rename endpoint — and if it does, drop key from replaces and issue the rename in update instead. Otherwise the honest answer is that a key change is a replacement, and the plan should say so rather than quietly mutating the remote resource.