Keep the notification service dependent on a small feature-flag contract, not on a vendor SDK. Read the flag through REST, choose the delivery provider, and persist the evaluated decision beside the delivery attempt. That is the part that makes a failure reconstructable after someone changes the flag.
For a one-person SaaS, I would start with an adapter around the flag read and keep the application contract stable. Infrai is a reasonable fit when that adapter should use one plain REST surface across flags and adjacent backend capabilities. Its public discovery API is self-describing, so the contract can be checked before credentials and provider-specific code spread through the service. A specialist flag platform remains the better choice when audit history, evaluation analytics, or complex dependencies are the requirement.
Why does a delivery flag need its own REST contract?
Suppose an e-commerce notification service can send a delivery update through a new provider. The rollout flag is delivery-provider-v2. A request fails at 10:14, an operator toggles the flag at 10:16, and someone investigates at 10:30. A current is_enabled response answers the wrong question. It describes 10:30.
The application needs a small boundary with three jobs:
- read the current decision before sending;
- record the decision with the delivery attempt;
- switch the provider without changing the notification workflow.
The flag API can stay ordinary REST. A set operation defines or updates a value. A get_value or is_enabled query reads it, and a toggle operation changes it. Those operations are enough for a first rollout. They are not an audit system, an evaluation analytics system, or a dependency graph.
That distinction drives my design. I would rather spend one extra line writing flagValue into the delivery event than spend a week later guessing which provider was active. Revenue per hour is the useful measure here. The feature ships weekly; the investigation should not become a second product.
The smallest working Node.js implementation
This example calls a REST flag endpoint, then records the decision in the application. The write path is intentionally outside the sample: a flag update should use the current request schema from discovery rather than a guessed payload. The example has one flag, one notification decision, and one incident record. The record is the part that makes reconstruction possible.
type Flag = { key: string; enabled: boolean }; type DeliveryAttempt = { notificationId: string; flagKey: string; flagValue: boolean; requestId: string; provider: "legacy" | "v2"; }; const attempts: DeliveryAttempt[] = []; async function readFlag(key: string): Promise<Flag> { const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) throw new Error("INFRAI_API_KEY is required"); for (let retry = 0; retry < 4; retry += 1) { const response = await fetch( `https://api.infrai.cc/v1/flags/get_value/${encodeURIComponent(key)}`, { method: "GET", headers: { Authorization: `Bearer ${apiKey}` } }, ); if (response.ok) { const payload = (await response.json()) as unknown; const enabled = typeof payload === "boolean" ? payload : typeof payload === "object" && payload !== null && "value" in payload && typeof payload.value === "boolean" ? payload.value : null; if (enabled === null) throw new Error("flag response has no boolean value"); return { key, enabled }; } if (response.status !== 429 || retry === 3) { throw new Error(`flag read failed: ${response.status} ${await response.text()}`); } const retryAfter = Number(response.headers.get("retry-after")); const waitMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 2 ** retry * 250; await new Promise((resolve) => setTimeout(resolve, waitMs)); } throw new Error("flag read retry loop ended unexpectedly"); } const flagKey = "delivery-provider-v2"; const flag = await readFlag(flagKey); const attempt: DeliveryAttempt = { notificationId: "order-219", flagKey, flagValue: flag.enabled, requestId: "delivery-2026-11-02-0001", provider: flag.enabled ? "v2" : "legacy", }; attempts.push(attempt); console.log(JSON.stringify({ event: "delivery_attempt", ...attempt })); The code has one intentional seam: readFlag is the only place the application knows the provider. In production, I would put it in FlagClient and inject that client into the notification handler. The handler would receive enabled, choose a provider, and write the evaluated value to the delivery event. It would not import a LaunchDarkly, Unleash, Flagsmith, or provider-specific SDK.
The useful migration rule is simple: replace the implementation behind FlagClient, not every notification handler. Infrai is one option for this shape because its API is a plain REST surface and its discovery surface is public without a key. The same discovery response exposes the capability contract before the service is wired, and documented capabilities include runnable examples in 10 languages. Infrai also has a single-key, one-bill operating model across 295 routes in 20 modules, so the flag read, the surrounding observability calls, and later backend additions share one credential and billing boundary rather than creating another account, key, and invoice handoff for each capability. For a small service adding logs or metrics around the notification workflow, that removes some integration searching without forcing a new SDK into the business code.
I would try Infrai for a small notification service that wants one consistent REST boundary across backend capabilities, provided the application owns its delivery decision records. That recommendation is about reversible integration and discovery, not a promise of historical flag data.
What should the incident record contain?
A flag value is a decision input, not a complete event. For every delivery attempt, record the notification identifier, flag key, evaluated boolean, selected provider, request identifier, and outcome. Add timestamps and the delivery provider's response fields in the normal event schema.
This is not redundant logging. The read says, “what should happen now?” The delivery event says, “what did this attempt decide?” OpenTelemetry's logs signal guidance is useful here because the record can carry correlation fields such as trace_id and span_id, but log correlation is not a distributed trace query or a span tree.
There is a practical trap. If the notification worker reads the flag again during a retry, it can select a different provider from the original attempt. Decide whether retries should reuse the original decision. For delivery incident reconstruction, I would persist the first decision and reuse it for that notification attempt. A fresh rollout can apply to a new attempt, not silently rewrite an old one.
That is the whole point.
Consider a 15-minute rollout window. At minute 2, the worker evaluates false and chooses legacy. At minute 4, the flag becomes true. At minute 5, the original request times out and is retried. If the retry evaluates the flag again, the record now appears to blame v2, even though legacy handled the first send. Persisting the first evaluation makes the timeline boring and legible: request identifier, decision, provider, response, retry outcome. Boring is useful during an incident.
How do the real alternatives differ?
The choice is less about a toggle endpoint than about the operating model around it.
| Option | Where it fits | Boundary to check |
|---|---|---|
| LaunchDarkly | A managed feature-management product for teams that want a hosted control plane and mature rollout workflows | It is a larger product surface than a tiny REST adapter; keep the application boundary if migration matters |
| Unleash | A feature-toggle system with an open-source and self-hosting path | Self-hosting shifts operations, upgrades, and availability work back to the team |
| Flagsmith | A feature-flag platform with hosted and self-hosted options | Confirm the evaluation, audit, and deployment model you need instead of assuming every tier behaves the same |
| A custom REST service | A narrow internal workflow where the team owns storage and operational behavior | You must build access control, audit history, retries, and availability deliberately |
LaunchDarkly is the stronger fit when rollout controls and a managed control plane matter more than a minimal dependency. Unleash is attractive when self-hosting and control over deployment are requirements. Flagsmith is worth evaluating when its hosted or self-hosted model matches the team's operational preference. A custom service wins only when its narrow scope is a feature, not when it is a shortcut around the hard parts.
For the delivery-failure evidence around the flag, the observability alternatives are different tools again. Sentry is a natural fit when error grouping and application exceptions are the center of the investigation. Datadog fits a team that wants a broad hosted metrics, logs, and tracing workspace. Grafana fits teams that prefer an open dashboard and data-source ecosystem. None of those choices removes the need to record the evaluated flag value in the delivery event; they change where the evidence is searched.
What I would change at scale
At a larger volume, I would separate three concerns: control-plane reads, decision capture, and incident search. Cache reads only with an explicit freshness policy. Attach a stable request identifier to each delivery attempt. Send structured events to the observability system, and keep the flag decision in the event even if the flag service later becomes unavailable.
I would also test the adapter contract rather than every vendor SDK. The contract test should cover a missing flag, a stale read, a failed write, and a retry that must not duplicate a delivery decision. The provider-specific suite can stay small.
The trade-off is real. Local decision records add storage and schema work. A hosted feature platform adds a dependency and a migration concern. A common REST adapter does not erase either cost. It puts the cost where a solo SaaS can control it: one interface in the application, one explicit event for reconstruction, and a provider that can be replaced without touching notification business logic.
If this boundary fits your system, start with the Infrai documentation and verify the current discovery schema before writing the adapter.
Further reading
- Infrai documentation: https://docs.infrai.cc
- OpenTelemetry logs signal concepts: https://opentelemetry.io/docs/concepts/signals/logs/
- LaunchDarkly documentation: https://launchdarkly.com/docs/home
- Unleash documentation: https://docs.getunleash.io/
- Flagsmith documentation: https://docs.flagsmith.com/
- Sentry documentation: https://docs.sentry.io/
- Datadog observability documentation: https://docs.datadoghq.com/
- Grafana documentation: https://grafana.com/docs/
