← Blog

Automation

Inngest + Drizzle vs Zapier sprawl: a media agency playbook

A nine-zap stack broke every Friday at 22:00. We replaced it with one Inngest workflow on Drizzle, and the Meta token rotation stopped paging the ops lead at midnight.

Jacob Molkenboer· Founder · A Brand New Company· 29 Jun 2025· 10 min
Brass relay switch wired with copper threads to a paper slip with green tab, beside leather blotter and red wax seal.

It was 22:14 on a Friday in late May when the operations lead at a Utrecht media-buying agency reached for her phone. The Zapier dashboard had been red since 19:30. LinkedIn Campaign Manager kept returning 429s, the Meta token had flipped to expired three hours after the system user was rotated by IT, and a TikTok daily-budget bump for a Dutch insurance client was waiting on someone to figure out which of nine zaps was supposed to own it. She paged the founder. The founder paged us.

What follows is the playbook we used to take that stack from nine brittle zaps to one Inngest workflow backed by a Drizzle schema. The agency runs paid media across Meta, TikTok, LinkedIn, and Google for around 80 active clients. They are 27 people. They had no engineers on staff. The brief was simple: stop the Friday pages, keep the same humans in control of the same campaigns.

How nine zaps become one outage

The original setup was honest. Every channel had a zap. Budget updates had a zap. End-of-day reporting had a zap. A separate zap reconciled the budget update with the reporting export. The accounting team had their own zap that read from a Google Sheet that another zap wrote to. Nine in total. Each one had been built by a different person over four years, and every one of them shared state through a Google Sheet.

This works until something rotates a token or a vendor changes a rate-limit window. Then you discover three things at once. First, none of the zaps know what the others did. Second, retries in Zapier do not survive across zaps, so if step three of zap B fails, the work zap A already pushed is not idempotent. Third, the failure email lands in a shared inbox that nobody reads after 18:00 on a Friday.

The agency had been compensating by hiring. Two ops people had their calendars blocked Mondays and Wednesdays to re-run failed automations by hand. That is the real cost of a Zapier sprawl: a salary you pay forever for work that should not exist.

What durable execution actually means

The core idea behind Inngest's step model is that every step in your function is checkpointed. If your code crashes between steps, the runtime resumes from the last successful one. If a step throws, the runtime retries that step (not the whole function) with the backoff you configured. Each step's result is durable, which means the next step can read it without re-doing the work.

For media-buying automation this maps cleanly. A campaign update is: fetch a token, push a write, log the result. If the write succeeds and the log fails, you do not want to push the write again. With Inngest, you don't. With Zapier, you do, and now your client's daily budget is doubled.

The same model is available in Temporal and AWS Step Functions. We picked Inngest because the agency had no DevOps appetite. It runs as a hosted service, accepts plain TypeScript, and the local dev server boots in under a second. For a 27-person shop without engineers, that mattered more than any feature.

One typed state store

Before any function got written, we put one Postgres database in front of the whole stack and modelled it with Drizzle. Every campaign, every token, every budget change, every push result lives in one place. The schema is roughly fifty lines.

import { pgTable, text, integer, timestamp, uuid } from "drizzle-orm/pg-core";

export const tokens = pgTable("tokens", {
  id: uuid("id").primaryKey().defaultRandom(),
  platform: text("platform").notNull(), // "meta" | "tiktok" | "linkedin"
  accountId: text("account_id").notNull(),
  accessToken: text("access_token").notNull(),
  refreshToken: text("refresh_token"),
  expiresAt: timestamp("expires_at").notNull(),
  rotatedAt: timestamp("rotated_at").notNull().defaultNow(),
});

export const budgetWrites = pgTable("budget_writes", {
  id: uuid("id").primaryKey().defaultRandom(),
  campaignId: text("campaign_id").notNull(),
  platform: text("platform").notNull(),
  amountCents: integer("amount_cents").notNull(),
  requestedBy: text("requested_by").notNull(),
  status: text("status").notNull(), // "queued" | "pushed" | "failed"
  pushedAt: timestamp("pushed_at"),
  externalRef: text("external_ref"),
});

Replacing nine Google Sheets with one Postgres schema removed about 60% of the original bugs without writing any new logic. State that used to live in row 412 of a sheet now has a foreign key.

The cost was about an hour of schema design and a Drizzle migration script. The payoff was that every downstream automation suddenly spoke the same language. Token rotation, budget pushes, and the accountant's reconciliation query all read from the same tables, which means there is one source of truth for "what did we tell Meta to spend yesterday." Before, that answer lived in three different spreadsheets and depended on which one Zapier wrote to last. The schema also let us bake in invariants Zapier could not enforce: a budget write cannot exist without a token row, a token row cannot exist without an account, and any orphan rows are rejected at insert time.

Surviving the Meta token rotation

Meta's Marketing API does not give you a renewable access token by default. The agency had been using a 60-day long-lived token on a single user account, which is what every Zapier tutorial recommends. When that user account got 2FA'd at the request of the security team, the token died, and the rotation cascaded into every zap that called Meta.

The right pattern is a system user with a never-expiring token, scoped to the ad account, written to your state store, and rotated by code. Meta's documentation on system users is the part of the docs everyone skips. It is the part you cannot skip.

Once the system user is in place, the agency's IT team can rotate normal employee 2FA without breaking anything. The token sits in the database, scoped to one ad account, and the next Inngest run picks it up. We rotate the system user's secret on a quarterly cadence with a one-off Inngest function that fires on the first Monday of every quarter; the workflow writes the new token to the same row and every subsequent budget push uses it without any other code change. The rotation is itself a durable workflow, so a half-completed rotation never leaves the agency with a mix of old and new credentials.

In Inngest, the token refresh becomes one step at the top of every function.

import { inngest } from "./client";
import { db, tokens, budgetWrites } from "./db/schema";
import { eq, and } from "drizzle-orm";

export const updateMetaBudget = inngest.createFunction(
  {
    id: "meta-budget-update",
    concurrency: { limit: 6, key: "event.data.adAccountId" },
    retries: 5,
  },
  { event: "campaign/budget.requested" },
  async ({ event, step }) => {
    const token = await step.run("get-token", async () => {
      const row = await db.query.tokens.findFirst({
        where: and(
          eq(tokens.platform, "meta"),
          eq(tokens.accountId, event.data.adAccountId),
        ),
      });
      if (!row) throw new Error("no token for account");
      if (row.expiresAt < new Date(Date.now() + 7 * 86_400_000)) {
        return rotateMetaSystemUserToken(row);
      }
      return row.accessToken;
    });

    await step.run("push", async () => {
      const res = await fetch(
        `https://graph.facebook.com/v20.0/${event.data.campaignId}`,
        {
          method: "POST",
          body: new URLSearchParams({
            daily_budget: String(event.data.amountCents),
            access_token: token,
          }),
        },
      );
      if (res.status === 429) throw new Error("rate-limited");
      if (!res.ok) throw new Error(await res.text());
    });

    await step.run("log", async () => {
      await db.insert(budgetWrites).values({
        campaignId: event.data.campaignId,
        platform: "meta",
        amountCents: event.data.amountCents,
        requestedBy: event.data.userId,
        status: "pushed",
        pushedAt: new Date(),
      });
    });
  },
);

The token refresh is checkpointed. The push is checkpointed. The log is checkpointed. If the runtime crashes after a successful push but before the log, Inngest resumes at the log step with the same token value it already fetched. The Meta side sees one write. The state store records one write. That is the entire invariant.

The TikTok midnight trap

TikTok Ads Manager resets its daily budget counter at midnight UTC, not in the advertiser's local timezone. For an agency in Amsterdam this means the counter rolls at 01:00 or 02:00 local depending on the season. We watched the old Zapier stack push a 23:55 Amsterdam budget bump and burn through it inside five minutes, because the counter was already most of the way to the existing cap.

Warning

TikTok daily budgets roll on UTC. If you push a budget change between 23:00 and 02:00 Amsterdam time, you are gambling on which day the change applies to. Queue the write, hold it until 01:30 Amsterdam, then push. Inngest's step.sleepUntil handles this in one line.

Inside the workflow this looks like:

const targetTime = nextOneThirtyAmsterdam(new Date());
await step.sleepUntil("wait-past-utc-rollover", targetTime);
await step.run("push-tiktok-budget", pushFn);

The sleep is durable. The function does not hold a worker open for two hours. Inngest persists the wake time and the worker goes back to the pool. When the wake fires, a fresh worker picks up the next step.

The Friday LinkedIn cliff

LinkedIn Marketing API has rolling rate limits, with separate buckets for read, write, and ads endpoints. The official numbers are listed in the throttle limits documentation. In practice, the bucket gets crowded on Friday afternoons because half the agencies in Europe are pushing weekend campaign updates between 16:00 and 22:00 local. By 21:00 Amsterdam the write bucket on any popular ad account is sitting close to empty.

The old stack hit this and gave up. The new workflow handles it with concurrency control plus the retry policy.

export const updateLinkedInBudget = inngest.createFunction(
  {
    id: "linkedin-budget-update",
    concurrency: { limit: 2, key: "event.data.accountId" },
    retries: 8,
    rateLimit: { limit: 100, period: "1m", key: "event.data.accountId" },
  },
  { event: "campaign/budget.requested" },
  async ({ event, step }) => { /* token, push, log */ },
);

The concurrency key throttles writes per ad account. The rate-limit option caps how often a given account can fire. The retry count is set high enough that a 429 at 21:30 will resolve itself by 22:00 without paging anyone. If it does not, Inngest sends one notification to the on-call channel and stops, instead of nine separate Zapier emails fired four minutes apart.

Migration order, not migration plan

We did not big-bang this. The agency had live spend running through the old zaps for 80 clients. The order we ran the migration in:

  1. Stand up Postgres and the Drizzle schema. No writes yet, just shape.
  2. Move token storage off Google Sheets. Read tokens from the database, but keep the zaps in charge of writes.
  3. Shadow-write a single channel (we picked TikTok because it carried the smallest spend) from Inngest, while the zap still owned the real write. Compare results for a week.
  4. Cut over TikTok writes to Inngest. Leave Meta and LinkedIn on Zapier.
  5. Repeat for Meta. Repeat for LinkedIn.
  6. Move reporting last. Reports tolerate a bad day. A doubled budget does not.

Each channel took about a week of part-time work. Most of that week was reading the vendor's API docs and writing one Drizzle migration. The Inngest functions themselves are around 60 lines each.

What the ops lead does now on Friday at 22:00

Nothing. The on-call channel posts one message at 06:00 Saturday with a summary: how many writes queued, how many pushed, how many retried, how many still pending. The retry-and-pending count for the first three months has been zero. The agency stopped paying overtime for the Monday morning rerun.

The dashboard the founder watches now is a single Drizzle query against the budget_writes table grouped by platform and status. Anything not 'pushed' surfaces in a separate row, with the originating request and the most recent retry error. That single view used to take 20 minutes of context-switching across Zapier task histories and three Google Sheets. It now loads in under a second. The two ops people who had Mondays and Wednesdays blocked for reruns got those days back; both are now working on client-facing reporting instead.

When we built this for the agency, the part we underestimated was the migration from the Google Sheet that finance had been using to reconcile spend against invoices. That sheet had three years of manual fixes baked into it, and the workflow needed to import it cleanly without breaking the Q1 close. We ended up writing a one-off Drizzle migration that ingested the sheet row by row, flagged rows that did not match the API record, and queued them for an accountant to review. That kind of cleanup is part of every process automation project we ship.

The smallest thing you can do today: open your most fragile Zapier zap and list, by hand, every external API call it makes and every place it writes state. If that list crosses three rows, the zap is already past its useful life. Make the list. Then decide.

Key takeaway

Durable execution plus a typed state store beats nine Zapier bots when you need retries, rate-limit handling, and a single ops view across Meta, TikTok, and LinkedIn.

FAQ

Why pick Inngest over Temporal or AWS Step Functions?

For a non-engineering team, Inngest is the lowest-friction option. The dev server boots in a second, it runs as a hosted service so there is no infrastructure to babysit, and the function code is plain TypeScript.

Can you keep Zapier for the simple parts?

Yes, and you should. Use Zapier for one-step Slack notifications or form-to-spreadsheet flows where retries do not matter. Move only the multi-step flows that touch money or state into Inngest.

How long does a migration like this take?

For a four-channel media-buying stack with around 80 active clients, plan on six to eight weeks of part-time engineering work, plus another two weeks of shadow-mode runs before you cut over fully.

What does this cost monthly compared to nine Zapier paid plans?

The agency saved roughly 200 euros per month on Zapier seats, but the real saving was about twelve hours per week of manual rerun work that no longer happens at all.

automationworkflowintegrationsprocess automationarchitecturecase study

Building something?

Start a project