Topics
Recent articles

DevOps & Cloud

Normalize Cloudflare Workflows Payloads Across Bindings and REST

Keep Cloudflare Workflows behavior consistent when the same workflow is triggered through a Worker binding or the REST API.

Table of Contents10 sections
Laptop, notebook, glasses, and phone arranged on a developer desk
A small normalization boundary keeps multiple workflow trigger paths aligned before orchestration begins.

A serverless workflow can behave perfectly when called from application code and then surprise you when the same operation is triggered through a REST API. The workflow is not necessarily broken. The two entry paths may simply encode the same logical input differently.

Cloudflare Workflows is a concrete example. A Worker binding accepts params as a JSON-serializable value, while the REST endpoint for creating an instance documents params as a JSON-encoded string. If both paths feed one workflow, normalize and validate the input at the boundary so the workflow receives one canonical payload shape.

The practical rule is simple: make trigger adapters responsible for transport differences, then keep workflow logic transport-agnostic.

Why the Two Trigger Paths Drift

Cloudflare documents several ways to trigger a Workflow, including Worker bindings and the REST API. With a Worker binding, creating an instance looks like this:

await env.MY_WORKFLOW.create({
  params: {
    targetRef: "main",
    dryRun: true
  }
});

The REST API has a different wire contract. Its create-instance endpoint accepts a request body whose params field is a JSON-encoded event payload. A client constructing that request therefore has an extra serialization boundary.

That distinction is small enough to overlook and large enough to create bugs. A manual tool may send a string where an internal caller uses an object. A boolean may become text. An optional field may disappear. A test may cover only the binding path and leave the external trigger unverified.

Do not push those differences deep into the workflow.

Define One Canonical Payload

Start with the shape the workflow actually needs, independent of how it was triggered.

type PublishInput = {
  targetRef: string;
  dryRun: boolean;
};

function validatePublishInput(value: unknown): PublishInput {
  if (!value || typeof value !== "object") {
    throw new Error("Payload must be an object");
  }

  const input = value as Record<string, unknown>;

  if (typeof input.targetRef !== "string" || input.targetRef.length === 0) {
    throw new Error("targetRef is required");
  }

  if (input.dryRun !== undefined && typeof input.dryRun !== "boolean") {
    throw new Error("dryRun must be a boolean");
  }

  return {
    targetRef: input.targetRef,
    dryRun: input.dryRun ?? false
  };
}

The validator is intentionally boring. It gives every caller the same output type and rejects ambiguous input before orchestration starts.

Cloudflare also warns that TypeScript type parameters do not validate incoming Workflow events at runtime. Types improve development ergonomics, but an external boundary still needs runtime validation.

Normalize the Worker Binding Path

The binding path already accepts an object, so the adapter only needs to validate it before creating the instance.

async function triggerFromBinding(
  workflow: Workflow,
  rawInput: unknown
) {
  const input = validatePublishInput(rawInput);

  return workflow.create({
    params: input
  });
}

This may feel redundant when the caller is trusted. It is still useful because it gives the binding path the same contract as every other entry point.

The workflow can now assume that targetRef is present and dryRun is a real boolean.

Normalize the REST Path Before Sending It

For a direct API client, validate the logical payload first and serialize only at the transport boundary.

async function triggerFromRest(
  accountId: string,
  workflowName: string,
  token: string,
  rawInput: unknown
) {
  const input = validatePublishInput(rawInput);

  const response = await fetch(
    `https://api.cloudflare.com/client/v4/accounts/${accountId}/workflows/${workflowName}/instances`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        params: JSON.stringify(input)
      })
    }
  );

  if (!response.ok) {
    throw new Error(`Workflow trigger failed: ${response.status}`);
  }

  return response.json();
}

The important detail is not the helper function itself. It is where serialization happens. Domain input remains an object until the REST adapter converts it to the wire format required by the API.

That keeps JSON.stringify out of the workflow’s business logic.

Keep the Workflow Transport-Agnostic

Once both adapters produce the same logical payload, the workflow should not care which route created the instance.

export class PublishWorkflow extends WorkflowEntrypoint<Env, PublishInput> {
  async run(event: WorkflowEvent<PublishInput>, step: WorkflowStep) {
    const input = validatePublishInput(event.payload);

    await step.do("prepare", async () => {
      return prepareTarget(input.targetRef, input.dryRun);
    });
  }
}

Validating again at the workflow boundary is reasonable defense in depth, especially when a workflow can be triggered by more than one system.

The key is that the validator sees the same canonical shape. It does not contain branches such as “if REST, parse this field differently” or “if scheduled, rename that option.”

This is the same architectural idea behind a reliable AI agent handoff contract: transfer control through an explicit, inspectable state boundary instead of relying on hidden assumptions from the previous executor.

Test Both Paths With the Same Cases

A normalization layer is only useful if the trigger paths are tested independently.

Use a shared table of cases:

Case Expected result
valid targetRef, omitted dryRun defaults to false
valid targetRef, dryRun: true accepted
missing targetRef rejected
empty targetRef rejected
dryRun: "true" rejected
malformed REST JSON rejected before workflow execution

Then run those cases through both adapters.

const cases = [
  {
    name: "defaults dryRun",
    input: { targetRef: "main" },
    expected: { targetRef: "main", dryRun: false }
  },
  {
    name: "keeps explicit dryRun",
    input: { targetRef: "release", dryRun: true },
    expected: { targetRef: "release", dryRun: true }
  }
];

Unit tests can prove the validator is deterministic. Integration tests should still exercise the real binding and REST surfaces because serialization, authentication, and API request construction are exactly where the two paths differ.

When debugging deployment-related failures, this separation also makes the investigation smaller. The same principle appears in the RayLabs guide to debugging broken links and deployment flows: isolate the boundary that can fail instead of treating the whole pipeline as one opaque system.

Failure Modes to Avoid

The most common mistake is parsing everywhere.

const payload =
  typeof event.payload === "string"
    ? JSON.parse(event.payload)
    : event.payload;

That can be a useful temporary compatibility shim, but it is a weak long-term contract. It allows upstream callers to keep sending inconsistent shapes and forces every consumer to remember the ambiguity.

A second mistake is coercing values aggressively:

const dryRun = Boolean(input.dryRun);

Boolean("false") is true, which is exactly the kind of silent behavior a normalization boundary should prevent. Validate types instead of guessing intent.

A third mistake is weakening safety checks for manual triggers. A REST call used for debugging should pass through the same authorization, validation, idempotency, and publication guards as an automated trigger. “Manual” describes who initiated the call, not a lower-risk execution mode.

Finally, do not rely on TypeScript alone for external input. Compile-time types disappear at runtime. Validate data where untrusted or differently encoded input crosses into your system.

A Small Adapter Is Usually Enough

You do not need a second workflow implementation for a second trigger mechanism.

Keep the architecture narrow:

Worker binding ----> validate ----+
                                  |
                                  +--> canonical payload --> workflow
                                  |
REST client -------> validate --> serialize

Transport-specific work stays at the edges. Validation defines the shared contract. The workflow remains focused on orchestration.

This pattern scales beyond Cloudflare Workflows. The same approach works when a job can start from an SDK, webhook, queue, CLI, scheduler, or admin tool. Each adapter translates its transport into one domain command, and the core system processes that command without caring how it arrived.

The Reusable Rule

When one workflow has multiple trigger paths, do not make the workflow understand every transport.

Instead:

  1. define one canonical payload;
  2. validate before crossing into orchestration;
  3. serialize only in the adapter that requires it;
  4. validate again at the execution boundary when appropriate;
  5. test every trigger path against the same behavioral cases;
  6. reject ambiguous values instead of coercing them;
  7. keep manual triggers behind the same safety gates as automated ones.

The payoff is not just cleaner code. It is operational consistency. A workflow triggered from a terminal, a Worker, or an external service should make the same decision from the same logical input. Boundary normalization is what makes that expectation enforceable.

Continue Exploring

You Might Also Like

View all articles