Skip to content

StadiaSoft

Build a SaaS API with Cloudflare Workers, D1 and Queues

A useful SaaS API has to do more than save a row. It must keep customers’ data separate, reject unauthorized requests, handle retries and report when background work fails. Cloudflare Workers, D1 and Queues can cover those needs in a compact architecture, provided you design the boundaries carefully.

This tutorial follows a realistic example: a team-management SaaS where users create a project, then the system provisions a workspace and sends an invitation asynchronously. The code and SQL are illustrative building blocks. A production implementation still needs your chosen identity provider, deployment pipeline, monitoring and review of current Cloudflare limits.

What each Cloudflare product does

Product Responsibility in the example Important boundary
Workers Receives HTTP requests, authenticates users, checks authorization and applies input rules Never assume a browser-supplied tenant ID proves access
D1 Stores tenants, projects, job records and deduplication keys Queries need indexes and workload testing
Queues Runs invitations and provisioning after the HTTP request Delivery can occur more than once; consumers need idempotency

The pattern is particularly useful for an MVP with clear product boundaries. StadiaSoft’s MVP development service helps teams define those boundaries before choosing implementation details.

Start with the request contract

Define one operation before creating tables:

POST /v1/projects
Authorization: Bearer <session token>
Idempotency-Key: <unique key for this create attempt>
Content-Type: application/json

{ "name": "Website relaunch" }

The response should return the project ID and a clear state, such as provisioning. The API should never take a trusted tenant_id from the JSON body. It should derive the user and allowed tenant from validated identity and membership data. Decide whether the caller can select among multiple authorized tenants via a route parameter or header, then verify that selection on every request.

If the product also has public signup or trial endpoints, protect those separately from authenticated API operations. The Turnstile, Workers and rate-limiting guide covers server-side token validation and edge controls for that entry point.

Before coding, document four outcomes: created, duplicate retry, invalid request and unauthorized request. This prevents accidental behavior changes when the frontend retries after a timeout.

Design tenant isolation into the schema

One D1 database can hold multiple tenants if every relevant row carries a tenant identifier and all reads and writes enforce it. A compact starting schema might look like this:

CREATE TABLE tenants (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL
);

CREATE TABLE memberships (
  tenant_id TEXT NOT NULL,
  user_id TEXT NOT NULL,
  role TEXT NOT NULL CHECK (role IN ('owner','member')),
  PRIMARY KEY (tenant_id, user_id)
);

CREATE TABLE projects (
  id TEXT PRIMARY KEY,
  tenant_id TEXT NOT NULL,
  name TEXT NOT NULL,
  status TEXT NOT NULL CHECK (status IN ('provisioning','active','failed')),
  created_at TEXT NOT NULL
);

CREATE INDEX projects_by_tenant_created
  ON projects (tenant_id, created_at DESC);

CREATE TABLE operation_keys (
  tenant_id TEXT NOT NULL,
  operation_key TEXT NOT NULL,
  project_id TEXT NOT NULL,
  PRIMARY KEY (tenant_id, operation_key)
);

CREATE TABLE jobs (
  id TEXT PRIMARY KEY,
  tenant_id TEXT NOT NULL,
  project_id TEXT NOT NULL,
  kind TEXT NOT NULL,
  status TEXT NOT NULL CHECK (status IN ('pending','enqueued','completed','failed')),
  attempts INTEGER NOT NULL DEFAULT 0
);

Use prepared statements with bound parameters. Every project query should constrain both project ID and tenant ID; an unscoped WHERE id = ? creates a route to cross-tenant disclosure even if project IDs are hard to guess. D1’s Worker binding supports prepared statements and batched statements. Cloudflare D1 Worker API

Create versioned migrations and run them in a tested order for local, staging and production databases. Cloudflare records applied D1 migrations so deployments can identify which changes have run. Cloudflare D1 migrations

Implement authorization before data access

The order matters:

  1. Validate the bearer token with your identity system.
  2. Resolve the authenticated user_id.
  3. Resolve the tenant selected by the request.
  4. Query memberships for that user_id and tenant.
  5. Check role permission for the action.
  6. Only then read or write tenant data.

Do not rely on a frontend guard, hidden field or user-supplied role. For every read by project ID, include the authorized tenant in the D1 query. For every write, validate the role again at the Worker boundary. This is ordinary authorization, independent of the Cloudflare services used.

Make project creation safe to retry

A browser may send the same request twice after a slow response. Require an Idempotency-Key for create operations that can trigger side effects. Scope that key to the tenant and operation. If the key already exists, return the original result rather than creating a second project.

D1’s batch() can execute related SQL statements in one transaction and roll them back if one fails. That allows the project row, operation key and pending job row to be created together. Cloudflare D1 batch behavior

const projectId = crypto.randomUUID();
const jobId = crypto.randomUUID();
const now = new Date().toISOString();

await env.DB.batch([
  env.DB.prepare(
    "INSERT INTO projects (id, tenant_id, name, status, created_at) " +
    "VALUES (?, ?, ?, 'provisioning', ?)"
  ).bind(projectId, tenantId, name, now),
  env.DB.prepare(
    "INSERT INTO operation_keys (tenant_id, operation_key, project_id) " +
    "VALUES (?, ?, ?)"
  ).bind(tenantId, idempotencyKey, projectId),
  env.DB.prepare(
    "INSERT INTO jobs (id, tenant_id, project_id, kind, status) " +
    "VALUES (?, ?, ?, 'provision', 'pending')"
  ).bind(jobId, tenantId, projectId),
]);

The example still needs a duplicate-key branch: on a uniqueness error, look up the existing key for the authenticated tenant and return its associated project. Do not blindly treat every SQL error as a duplicate; database outages and invalid migrations need different handling.

Connect D1 to Queues without losing work

Writing a database row and publishing a queue message are two different operations. There is no single transaction that automatically commits both D1 and Queues. If the Worker commits the project but crashes before enqueueing, the job can remain pending forever. If it enqueues first but the database write fails, the consumer may receive an unknown project.

Use the jobs table as a small outbox:

  1. Commit the project and pending job row together in D1.
  2. Attempt env.PROVISION_QUEUE.send({ jobId }).
  3. Mark the job enqueued only after successful enqueue, and only while its state is still pending. A fast consumer may already have marked it completed.
  4. Run a scheduled reconciliation task that finds old pending jobs and retries enqueueing.
  5. Keep the consumer idempotent because reconciliation can produce duplicate messages.

This is an architectural pattern, not an assertion that the queue and database become exactly once. Cloudflare Queues documents at-least-once delivery, so duplicates are an expected condition to design for. Cloudflare Queues delivery guarantees

For more complex external API workflows, StadiaSoft’s API integration service addresses the same problem across payment, CRM and notification systems.

Process each queue message safely

The queue payload should contain stable identifiers, not a full copy of sensitive project data. The consumer can read the current record, verify its state and perform the side effect. Use the jobId as the idempotency key when calling an external provider that supports one; otherwise persist a delivery state that prevents duplicate effects where possible.

export default {
  async queue(batch, env) {
    for (const message of batch.messages) {
      const { jobId } = message.body;
      const job = await env.DB.prepare(
        "SELECT id, tenant_id, project_id, status FROM jobs WHERE id = ?"
      ).bind(jobId).first();

      if (!job || job.status === "completed") {
        message.ack();
        continue;
      }

      try {
        // Fetch current project and perform the idempotent external action.
        // Persist a terminal success state only after it has completed.
        await provisionWorkspace(job, env);
        await env.DB.prepare(
          "UPDATE jobs SET status = 'completed' WHERE id = ?"
        ).bind(jobId).run();
        message.ack();
      } catch (error) {
        message.retry();
      }
    }
  },
};

The placeholder provisionWorkspace is where most real integration work lives. It needs provider-specific timeout behavior, a stable idempotency key and a way to determine whether a timed-out request actually succeeded. A local database flag cannot guarantee exactly-once external side effects on its own.

Cloudflare allows messages to be acknowledged or retried individually. A failed unacknowledged message in a batch can cause the batch to be delivered again, so explicit handling prevents already completed work from being repeated unnecessarily. Mark completion with a conditional database update and recheck external side effects before replaying a timed-out call. Cloudflare Queues retries and acknowledgments

Configure retries and a dead-letter queue

Transient errors should retry. Permanent failures need inspection, not infinite repetition. Configure a retry limit and a dead-letter queue (DLQ), then assign someone to monitor and replay or resolve DLQ entries. Cloudflare states that messages reaching the retry limit are sent to the configured DLQ; without one, they are eventually discarded. Cloudflare dead-letter queues

Store a safe error category and last-attempt time in D1. Avoid placing access tokens or personal data in log messages. Give support staff a way to inspect a failed job by ID and retry it after the underlying issue is fixed.

Test the assumptions that decide whether D1 fits

D1 may be a strong fit for an early SaaS product, but you should measure the actual workload. Model the number of tenants, data per tenant, common queries, concurrent writes and reporting needs. Create indexes for the query patterns you will run, then load-test representative data and check current platform limits. Cloudflare D1 limits

If you need read replication, Cloudflare says it must be used through the D1 Sessions API; simply enabling replication does not move every read to a replica. Cloudflare D1 read replication

Choose a different database when the workload requires features, consistency patterns, operational controls or scale that D1 cannot meet economically or cleanly. An architecture decision should come from measurements and requirements, not from a vendor diagram.

Production acceptance checklist

Area Evidence to require before launch
Identity Invalid, expired and wrong-tenant credentials are rejected
Authorization Every read and write is scoped to the authenticated tenant
Data Migrations run in staging; backups and recovery approach are documented
Idempotency Repeated creates return one project and one logical job
Queue Duplicate and out-of-order deliveries do not duplicate side effects
Reconciliation Pending jobs are detected and re-enqueued
Failure Retry limit, DLQ owner and manual recovery path are defined
Observability Request IDs connect API responses, jobs and provider calls
Capacity Representative read/write load has been tested against current D1 limits

The payoff of this design is not fewer components. It is a clear contract between the request, database and background work. If you are planning a multi-tenant product, StadiaSoft’s SaaS development team can help shape that contract and build the first release around measurable requirements.

If the product also accepts customer images or files, plan the same tenant checks and job recovery for the upload pipeline. A separate guide to Cloudflare R2, Images and Workers follows in this series.

Technical sources reviewed September 25, 2026. Recheck Cloudflare pricing, limits and API behavior before production deployment.

Frequently asked questions

Is Cloudflare D1 suitable for every SaaS database?

No. Test your storage size, query shape, write frequency, geographic needs and operational requirements against current D1 limits before deciding.

Do Cloudflare Queues deliver each message exactly once?

No. Queues provide at-least-once delivery by default. Consumers must tolerate duplicate messages and protect external effects with idempotency.

Should an API return success before a background job completes?

It can return an accepted response with a job ID after the request and enqueue workflow succeeds, but it should make clear that background work is still in progress. A later status endpoint or notification should report completion or failure.