← Blog

Automation

Inngest + Drizzle vervangen Zapier bij mediabureau

Een stack van negen zaps ging elke vrijdag om 22:00 op zijn gat. We vervingen 'm door één Inngest-workflow op Drizzle, en de Meta-tokenrotatie belt de ops-lead 's nachts niet meer.

Jacob Molkenboer· Oprichter · A Brand New Company· 29 jun 2025· 10 min
Messing relais met koperen draden naar papieren strookje met groen labeltje, naast leren onderlegger en rood lakzegel.

Het was 22:14 op een vrijdag eind mei toen de ops-lead van een Utrechts mediabureau naar haar telefoon greep. Het Zapier-dashboard stond al sinds 19:30 in het rood. LinkedIn Campaign Manager bleef 429's teruggeven, het Meta-token was drie uur na de rotatie door IT op 'verlopen' gesprongen, en een TikTok-budgetbump voor een Nederlandse verzekeraar wachtte tot iemand had uitgepuzzeld welke van negen zaps daarvoor verantwoordelijk was. Ze belde de oprichter. De oprichter belde ons.

Hieronder staat het draaiboek waarmee we die stack van negen fragiele zaps terugbrachten naar één Inngest-workflow op een Drizzle-schema. Het bureau koopt betaalde media in op Meta, TikTok, LinkedIn en Google voor zo'n 80 actieve klanten. Ze zijn met 27. Geen engineer in vaste dienst. De briefing was simpel: stop de vrijdagse pagings, en hou dezelfde mensen in controle over dezelfde campagnes.

Hoe negen zaps één storing worden

De oude opzet was eerlijk. Elk kanaal had een zap. Budgetwijzigingen hadden een zap. End-of-day rapportage had een zap. Een aparte zap stemde de budgetwijziging af op de rapportage-export. De boekhouding had hun eigen zap die uit een Google Sheet las waar weer een andere zap in schreef. Negen in totaal. Elk gebouwd door iemand anders, verspreid over vier jaar, en allemaal deelden ze state via een Google Sheet.

Dit werkt prima totdat iets een token roteert of een leverancier zijn rate-limit window aanpast. Dan ontdek je drie dingen tegelijk. Eén: geen van de zaps weet wat de anderen hebben gedaan. Twee: retries in Zapier overleven de overgang tussen zaps niet, dus als stap drie van zap B faalt, is het werk dat zap A al heeft gepusht niet idempotent. Drie: de foutmail komt aan in een gedeelde inbox die niemand na 18:00 op een vrijdag nog leest.

Het bureau compenseerde door bij te werven. Twee ops-mensen hadden hun agenda op maandag en woensdag geblokt om gefaalde automatiseringen met de hand opnieuw te draaien. Dat is de echte kost van een Zapier-wirwar: een salaris dat je voor altijd betaalt voor werk dat niet zou moeten bestaan.

Wat durable execution echt betekent

Het kernidee achter het step-model van Inngest is dat elke stap in je functie wordt gecheckpoint. Als je code tussen twee stappen crasht, hervat de runtime vanaf de laatst geslaagde stap. Als een stap een fout gooit, retryt de runtime alleen die stap (niet de hele functie) met de backoff die je hebt ingesteld. Het resultaat van elke stap is duurzaam, dus de volgende stap kan het uitlezen zonder het werk opnieuw te doen.

Voor media-buying automation valt dat netjes op zijn plek. Een campagne-update is: haal een token op, push een write, log het resultaat. Als de write slaagt en het loggen faalt, wil je de write niet nog een keer pushen. Met Inngest hoef je dat niet. Met Zapier wel, en nu is het dagbudget van je klant verdubbeld.

Hetzelfde model bestaat in Temporal en AWS Step Functions. We kozen Inngest omdat het bureau geen DevOps-honger had. Het draait als gehoste dienst, accepteert gewone TypeScript, en de lokale dev-server is in minder dan een seconde op. Voor een bureau van 27 mensen zonder engineers woog dat zwaarder dan welke feature dan ook.

Eén getypeerde state store

Voordat er ook maar één functie geschreven werd, hebben we één Postgres-database voor de hele stack gezet en die gemodelleerd met Drizzle. Elke campagne, elk token, elke budgetwijziging, elk push-resultaat staat op één plek. Het schema is ongeveer vijftig regels.

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"),
});

Negen Google Sheets vervangen door één Postgres-schema haalde zo'n 60% van de oorspronkelijke bugs weg, zonder ook maar één regel nieuwe logica te schrijven. State die vroeger in regel 412 van een sheet stond, heeft nu een foreign key.

Het kostte zo'n uur schema-ontwerp en een Drizzle-migratiescript. De winst was dat elke downstream-automation opeens dezelfde taal sprak. Token-rotatie, budget-pushes en de afstemmingsquery van de boekhouder lezen allemaal uit dezelfde tabellen, wat betekent dat er één bron van waarheid is voor 'wat hebben we Meta gisteren laten uitgeven'. Vroeger leefde dat antwoord in drie verschillende spreadsheets en hing het af van welke Zapier als laatste schreef. Het schema liet ons ook invarianten inbouwen die Zapier niet kon afdwingen: een budget-write kan niet bestaan zonder een token-rij, een token-rij kan niet bestaan zonder een account, en losse weesrijen worden bij insert geweigerd.

De Meta-tokenrotatie overleven

De Marketing API van Meta geeft je standaard geen verniewbare access token. Het bureau gebruikte een 60-daagse long-lived token op één gebruikersaccount, precies wat elke Zapier-tutorial je aanraadt. Toen dat account op verzoek van het security-team 2FA opgelegd kreeg, ging het token kapot, en de rotatie cascadeerde door elke zap die Meta aanriep.

Het juiste patroon is een system user met een token dat nooit verloopt, gescoped op het ad account, geschreven naar je state store, en geroteerd door code. De documentatie van Meta over system users is het stukje docs dat iedereen overslaat. Dat is het stukje dat je niet mag overslaan.

Zodra de system user staat, kan de IT-afdeling de normale 2FA van werknemers roteren zonder dat er iets breekt. Het token zit in de database, gescoped op één ad account, en de volgende Inngest-run pakt 'm op. We roteren het geheim van de system user op een kwartaalcadans met een eenmalige Inngest-functie die op de eerste maandag van elk kwartaal afgaat. De workflow schrijft het nieuwe token naar dezelfde rij en elke volgende budget-push gebruikt 'm zonder verdere code-aanpassing. De rotatie is zelf een durable workflow, dus een half voltooide rotatie laat het bureau nooit achter met een mengeling van oude en nieuwe credentials.

In Inngest wordt de token-refresh één stap bovenaan elke functie.

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(),
      });
    });
  },
);

De token-refresh is gecheckpoint. De push is gecheckpoint. Het loggen is gecheckpoint. Als de runtime crasht na een geslaagde push maar voor het loggen, hervat Inngest bij de log-stap met dezelfde token die het al had opgehaald. De Meta-kant ziet één write. De state store registreert één write. Dat is de hele invariant.

De TikTok-middernachtval

TikTok Ads Manager reset zijn dagbudget-teller om middernacht UTC, niet in de tijdzone van de adverteerder. Voor een bureau in Amsterdam betekent dat dat de teller om 01:00 of 02:00 lokale tijd doordraait, afhankelijk van het seizoen. We zagen de oude Zapier-stack om 23:55 Amsterdamse tijd een budgetbump pushen die binnen vijf minuten was opgebrand, omdat de teller toen al tegen de bestaande cap aan zat.

Pas op

TikTok-dagbudgetten draaien op UTC. Als je tussen 23:00 en 02:00 Amsterdamse tijd een budgetwijziging pusht, gok je op welke dag de wijziging valt. Zet de write in de wachtrij, hou 'm vast tot 01:30 Amsterdam, en push dan. Inngest's step.sleepUntil regelt dat in één regel.

Binnen de workflow ziet dat er zo uit:

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

De sleep is duurzaam. De functie houdt geen worker twee uur lang bezet. Inngest bewaart de wake-up tijd en de worker gaat terug naar de pool. Als de wake-up afgaat, pakt een verse worker de volgende stap op.

De vrijdagse LinkedIn-cliff

De Marketing API van LinkedIn heeft rollende rate limits, met aparte buckets voor read, write en ads endpoints. De officiële getallen staan in de throttle limits documentation. In de praktijk loopt de bucket op vrijdagmiddag vol, omdat de helft van de Europese bureaus tussen 16:00 en 22:00 lokale tijd hun weekend-campagnes aan het pushen is. Tegen 21:00 Amsterdam is de write-bucket op elk populair ad account zo goed als leeg.

De oude stack liep daar tegenaan en gaf het op. De nieuwe workflow vangt dat op met concurrency-controle en het retry-beleid.

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 */ },
);

De concurrency key throttelt writes per ad account. De rate-limit optie kapt af hoe vaak een gegeven account mag vuren. De retry-count staat hoog genoeg dat een 429 om 21:30 zichzelf voor 22:00 oplost, zonder dat er iemand gebeld wordt. Lukt dat niet, dan stuurt Inngest één bericht naar het on-call kanaal en stopt, in plaats van negen aparte Zapier-mails met vier minuten tussenpauze.

Migratievolgorde, geen migratieplan

We hebben dit niet big-bang gedaan. Het bureau had live spend lopen via de oude zaps voor 80 klanten. De volgorde waarin we de migratie uitvoerden:

  1. Zet Postgres en het Drizzle-schema op. Nog geen writes, alleen de vorm.
  2. Verplaats token-opslag van Google Sheets naar de database. Lees tokens uit Postgres, maar laat de zaps verantwoordelijk voor writes.
  3. Shadow-write één kanaal (we kozen TikTok omdat daar het kleinste budget op stond) vanuit Inngest, terwijl de zap de echte write blijft doen. Vergelijk de resultaten een week lang.
  4. Cut-over de TikTok-writes naar Inngest. Laat Meta en LinkedIn op Zapier staan.
  5. Herhaal voor Meta. Herhaal voor LinkedIn.
  6. Verplaats rapportage als laatste. Rapportages overleven een slechte dag. Een verdubbeld budget niet.

Elk kanaal kostte zo'n week part-time werk. Het meeste van die week ging op aan het lezen van de API-docs van de leverancier en het schrijven van één Drizzle-migratie. De Inngest-functies zelf zijn ongeveer 60 regels per stuk.

Wat de ops-lead nu doet op vrijdag om 22:00

Niets. Het on-call kanaal post om 06:00 zaterdag één bericht met een samenvatting: hoeveel writes in de wachtrij, hoeveel gepusht, hoeveel geretried, hoeveel nog open. De retry-en-open-teller staat de eerste drie maanden op nul. Het bureau betaalt geen overuren meer voor de maandagochtend-rerun.

Het dashboard dat de oprichter nu in de gaten houdt is één Drizzle-query op de budget_writes tabel, gegroepeerd op platform en status. Alles wat niet 'pushed' is, komt in een aparte rij naar boven, met de oorspronkelijke request en de meest recente retry-error. Voor diezelfde blik moest hij vroeger 20 minuten context-switchen tussen Zapier-task-histories en drie Google Sheets. Nu laadt 'm in minder dan een seconde. De twee ops-mensen die op maandag en woensdag geblokt stonden voor reruns, hebben die dagen terug. Beiden werken nu aan klant-facing rapportages.

Toen we dit voor het bureau bouwden, was het stuk dat we onderschatten de migratie van de Google Sheet waarmee finance spend afstemde tegen facturen. In die sheet zaten drie jaar handmatige fixes ingebakken, en de workflow moest 'm schoon importeren zonder de Q1-afsluiting te breken. We schreven uiteindelijk een eenmalige Drizzle-migratie die de sheet rij voor rij inlas, rijen die niet matchten met het API-record flagde, en die voor de boekhouder in een review-wachtrij zette. Dat soort opschoonwerk hoort bij elk procesautomatisering-traject dat we opleveren.

Het kleinste dat je vandaag kan doen: open je meest fragiele Zapier-zap en noteer met de hand elke externe API-call die 'm doet en elke plek waar 'm state schrijft. Komt die lijst boven drie rijen uit, dan is de zap z'n nuttige leven al voorbij. Maak het lijstje. Beslis dan.

Kern

Durable execution plus een getypeerde state store wint van negen Zapier-bots zodra je retries, rate-limit handling en één ops-blik over Meta, TikTok en LinkedIn nodig hebt.

FAQ

Waarom Inngest en niet Temporal of AWS Step Functions?

Voor een team zonder engineers is Inngest de optie met de laagste frictie. De dev-server is in een seconde op, het draait als gehoste dienst dus er is geen infrastructuur om bij te houden, en de functiecode is gewoon TypeScript.

Kan je Zapier blijven gebruiken voor de simpele dingen?

Ja, en dat moet je ook doen. Gebruik Zapier voor eenstaps Slack-meldingen of form-naar-spreadsheet flows waar retries er niet toe doen. Verhuis alleen de multi-step flows die geld of state aanraken naar Inngest.

Hoe lang duurt zo'n migratie?

Voor een vierkanaals media-buying stack met zo'n 80 actieve klanten plan je zes tot acht weken part-time engineering-werk, plus nog twee weken shadow-mode runs voordat je volledig overstapt.

Wat kost dit per maand vergeleken met negen Zapier betaalplannen?

Het bureau bespaarde zo'n 200 euro per maand op Zapier-seats, maar de echte winst was zo'n twaalf uur per week handwerk dat nu helemaal niet meer hoeft.

automationworkflowintegrationsprocess automationarchitecturecase study

Iets bouwen?

Start een project