← Blog

Process automation

Van HubSpot workflows naar een Postgres-queue: een rebuild

Op een dinsdag in februari zag de recruiting ops lead van een Rotterdams uitzendbureau hoe haar HubSpot workflows 4.200 taken in de wachtrij zetten en stilvielen. We hielpen haar ze te vervangen.

Jacob Molkenboer· Oprichter · A Brand New Company· 4 jun 2026· 9 min
Messing kaartenbak met papieren tickets op ivoren bureau, één ticket met groen tabje, leren onderlegger, lakzegel.

Het was een dinsdag in februari. Anouk, de recruiting ops lead bij een uitzendbureau aan de Rotterdamse Westblaak, staarde naar haar HubSpot workflows dashboard. De workflow "Kandidaat naar stage interview" had 4.217 enrolments in de wachtrij staan. De volgende workflow in de keten, "Account manager informeren", stond al achtendertig minuten op "starting". Drie recruiters op haar verdieping zaten te wachten tot de kandidaatmail zou afgaan, zodat ze konden zien wie wat te horen had gekregen. Hij zou afgaan, ooit, in batches, in een volgorde die niemand uit de UI kon reconstrueren.

Die avond mailde ze ons. Zes weken later waren de 14 workflows die haar plaatsings-pipeline draaiden weg, vervangen door een Python-worker van 200 regels die uit een Postgres-queue las. Deze post gaat over wat we bouwden, waarom HubSpot niet meer paste, en de ene Postgres-feature die het hele systeem mogelijk maakt.

Wat de workflows daadwerkelijk deden

Het bureau plaatst contractors bij mid-market bedrijven in Zuid-Holland. Zo'n 60 mensen intern. Hun HubSpot-pipeline was de gebruikelijke: kandidaat gesourced, intro-gesprek, klantinterview, aanbod, plaatsing, factuur, contractverlenging. Bij elke stage-wijziging ging er een keten van workflows af:

  • Stuur de kandidaat een mail die past bij de stage.
  • Maak een HubSpot-taak aan voor de recruiter die de kandidaat beheert.
  • Plaats een bericht in het Slack-kanaal voor het klantaccount.
  • Voeg een regel toe aan de master placement Google Sheet (Finance leest daaruit).
  • Werk de LinkedIn outreach-status van de kandidaat bij in een aparte tool.
  • Bij stage "plaatsing": vuur een webhook naar Exact Online om een conceptfactuur aan te maken.

Veertien workflows, rond de 80 branches, custom code actions in zes ervan. Niets exotisch. En alles brak onder druk.

Waar de HubSpot workflow-engine je tegen gaat werken

HubSpot workflows zijn uitstekend voor waar ze voor bedoeld zijn: marketing-achtige drip-sequences over CRM-records, met conditionele branches die iemand zonder technische achtergrond kan aanpassen. De engine begint je tegen te werken zodra ops-logica écht wordt.

Je kunt geen state lezen uit een andere workflow tijdens een run. Workflows delen geen scratch space. Als "Account manager informeren" moet weten wat "Kandidaatmail sturen" heeft besloten, prop je die beslissing terug op het contact als custom property en hoop je dat niets anders erop schrijft. Het bureau had negen custom properties die uitsluitend bestonden om state tussen workflows door te geven. Geen ervan had voor een recruiter een betekenisvolle naam.

Je ziet niet waarom een branch is gekozen. De workflow-log laat zien dat een contact branch A in ging. Hij laat niet zien wat de waarde was van de conditie die die beslissing nam. Als de verkeerde mail uitgaat, mag je gokken.

Custom code actions hebben rate limits. De gedocumenteerde limieten van HubSpot op serverless action-executies zijn van die getallen die niet uitmaken tot ze het ineens wel doen. Het bureau liep er maandagochtend tegenaan, als 200 stage-wijzigingen uit weekendinterviews tegelijk binnenkwamen.

Je kunt een workflow-wijziging niet met git deployen. Elke edit is klik-en-save in de UI. Een wijziging reviewen is schermdelen. Terugrollen is proberen te herinneren hoe de branch er gisteren uitzag.

De kostenregel blijft schuiven. Operations Hub Professional zit rond de €720 per maand voor de tier die custom code actions toelaat. Het bureau betaalde extra voor overage op action-executies, en die regel werd alleen maar groter.

Waarschuwing

Als je meer dan vijf custom code actions hebt die echte branching-logica draaien over je workflows heen, heb je een queue en een worker. Je hebt ze alleen nog geen naam gegeven. HubSpot is dan de GUI bovenop een ongedocumenteerde service die je betaalt om te hosten.

De Postgres-queue, in vorm

De vervanging is met opzet niet sexy. Eén tabel, één worker, één cron, plus de bestaande HubSpot-webhook.

CREATE TABLE job (
  id          bigserial PRIMARY KEY,
  kind        text NOT NULL,
  payload     jsonb NOT NULL,
  run_at      timestamptz NOT NULL DEFAULT now(),
  attempts    int NOT NULL DEFAULT 0,
  state       text NOT NULL DEFAULT 'ready',
  last_error  text,
  dedupe_key  text,
  created_at  timestamptz NOT NULL DEFAULT now(),
  UNIQUE (kind, dedupe_key)
);

CREATE INDEX job_ready_idx
  ON job (run_at)
  WHERE state = 'ready';

HubSpot vuurt zijn bestaande webhook als een deal of contact van stage verandert. Een klein Flask-endpoint leest de webhook en doet één insert per side-effect: één voor de mail, één voor de Slack-ping, één voor de regel in de Sheet, één voor de Exact Online conceptfactuur. De dedupe_key is meestal contact_id:stage:iso_week, en de UNIQUE-constraint maakt daar gratis idempotentie van. Een opnieuw afgevuurde webhook produceert dezelfde key, en de tweede insert valt stilletjes weg.

De worker is een Python-loop die per keer één job claimt en de bijhorende handler uitvoert. De handlers zijn elk 20 tot 40 regels: HTTP POST naar Slack, HubSpot API-call om een taak aan te maken, gspread-append, Exact Online OAuth-call.

De ene truc: SELECT FOR UPDATE SKIP LOCKED

Dit is de Postgres-feature die "queue in je bestaande database" een serieuze keuze maakt in plaats van een starter-project-compromis. Hij landde in Postgres 9.5 in 2016 en is echt alles wat je nodig hebt. Het stuk van Brandur Leach over Postgres job queues is de canonieke referentie als je de volledige theorie wil.

WITH next AS (
  SELECT id FROM job
   WHERE state = 'ready' AND run_at <= now()
   ORDER BY run_at
   FOR UPDATE SKIP LOCKED
   LIMIT 1
)
UPDATE job
   SET state = 'running',
       started_at = now(),
       attempts = attempts + 1
  FROM next
 WHERE job.id = next.id
RETURNING job.*;

Drie workers kunnen dit tegelijk draaien zonder met elkaar te vechten. De row-level lock op de CTE-pick zorgt dat de claim van worker A onzichtbaar is voor de claim van worker B. Workers die midden in een job crashen laten hun rij in running staan met een verhoogde attempts; een aparte reaper-query reset alles wat te lang draait. Er is geen Redis. Er is geen SQS. Er is geen Celery. Er is één tabel.

De volledige worker-loop:

import time, traceback
import psycopg
from psycopg.rows import dict_row
from handlers import HANDLERS

DSN = "postgresql:///agency"
CLAIM_SQL = open("claim.sql").read()

def claim(conn):
    with conn.cursor() as cur:
        cur.execute(CLAIM_SQL)
        row = cur.fetchone()
        conn.commit()
        return row

def finish(conn, job_id, ok, err=None):
    with conn.cursor() as cur:
        if ok:
            cur.execute("UPDATE job SET state='done' WHERE id=%s", (job_id,))
        else:
            cur.execute(
                """UPDATE job
                      SET state = CASE WHEN attempts >= 5 THEN 'dead' ELSE 'ready' END,
                          run_at = now() + (interval '30 seconds' * (attempts * attempts)),
                          last_error = %s
                    WHERE id = %s""",
                (err, job_id),
            )
        conn.commit()

def main():
    with psycopg.connect(DSN, row_factory=dict_row, autocommit=False) as conn:
        while True:
            job = claim(conn)
            if not job:
                time.sleep(2)
                continue
            try:
                HANDLERS[job["kind"]](job["payload"])
                finish(conn, job["id"], ok=True)
            except Exception:
                finish(conn, job["id"], ok=False, err=traceback.format_exc())

if __name__ == "__main__":
    main()

Een aparte cron die elke minuut draait, regelt de crash-recovery die de worker-loop bewust negeert. Alles wat langer dan vijf minuten in running blijft hangen wordt gereset, op de aanname dat geen eerlijke handler zo lang draait:

UPDATE job
   SET state = 'ready',
       last_error = 'reaped after worker timeout'
 WHERE state = 'running'
   AND started_at < now() - interval '5 minutes';

Cron hem elke minuut. Dat is het complete crash-recovery-verhaal.

Dat, plus de handler-module, plus de webhook-receiver, is het hele systeem. Het retry-beleid zit in de SQL. Het dead-letter-gedrag zit in de SQL. Observability is SELECT state, count(*) FROM job GROUP BY state, op één Grafana-paneel.

Wat in HubSpot bleef

Dit is het deel dat de meeste "we hebben onze CRM vervangen"-verhalen verkeerd doen. Het bureau heeft HubSpot níet vervangen. HubSpot is goed in CRM zijn: contactrecords, pipelines, properties, marketing-mail, het deal-bord waar de recruiters in leven. Dat vervangen was een jaar pijn voor niets geweest.

We vervingen de orkestratielaag. HubSpot is nog steeds eigenaar van de data. De Python-worker leest uit HubSpot via de API, schrijft terug via de API, en gebruikt Postgres alleen voor de queue van de side-effects. Voor een recruiter veranderde er niets, behalve dat de mails nu in seconden vuren in plaats van "ergens vandaag".

De cutover, één workflow per keer

We zijn niet in één keer omgegaan. De cutover liep vier weken parallel aan het oude systeem.

Week één. Webhook-receiver live, queue gevuld, geen handlers die vuurden. We keken of de queue-diepte de workflow-enrolments matchte, een paar seconden marge. Dat was de stille dry-run.

Week twee. Eén handler live: de Slack-ping. De bijhorende HubSpot-workflow stond uit. Als de nieuwe code een bericht zou laten vallen, zouden recruiters het binnen minuten merken en zouden we het horen. Dat gebeurde niet.

Week drie. De rest van de handlers, één per dag, telkens gekoppeld aan het uitzetten van de bijhorende workflow. De zwaarste dag was de conceptfactuur in Exact Online. De gepubliceerde rate limit van Exact is 60 requests per minuut, maar de tokens van één integratie throttlen onder volgehouden druk stilletjes op ongeveer 20 per minuut. Dat leerden we door de dead state te zien vollopen op een woensdagmiddag om 14:30. We voegden een concurrency-cap per handler toe, geïmplementeerd als een Postgres advisory lock op de handler-naam, zodat er altijd maar één Exact-job tegelijk draait, ongeacht hoeveel er in de wachtrij staan. Vijf regels SQL, één regel Python.

Week vier. De legacy-workflows werden gearchiveerd, niet verwijderd. Drie maanden later hebben we ze gewist. Niets had er nog naar gegrepen.

Zes maanden verder

Cijfers uit de eerste helft van 2026 bij het bureau, vergeleken met dezelfde maanden in 2025:

  • 14 workflows teruggebracht naar 7 job-kinds. De zeven kinds matchen één-op-één met side-effects die we in één zin kunnen benoemen.
  • Gemiddelde notificatielatentie, van stage-wijziging tot Slack-ping: van ongeveer 9 minuten naar zo'n 3 seconden.
  • Custom code action overage: weg. Operations Hub Professional blijft, de overage-regel ging naar nul.
  • Eén productie-incident in zes maanden: een typo in een Slack-kanaalnaam. De job probeerde het opnieuw, faalde vijf keer, belandde in dead, en Anouk fixte het in drie minuten.
  • Regels Python: 211. Regels SQL: 38. Regels YAML: 0.

Wat Anouk het vaakst zegt als ze het systeem aan andere ops leads laat zien is "Ik kan het lezen". Dat is de echte winst. De workflow-editor was onleesbaar: 80 branches in een graaf die niet op een monitor paste. Het Python-bestand past op een monitor.

Iets wat de cijfers niet vangen: hoe het gesprek tussen Anouk en haar CTO veranderde. Eerder, als ops een workflow aangepast wilde hebben, opende ze een ticket en wachtte een week. Nu opent ze een pull request op het handlers-bestand, krijgt voor de lunch een review, en deployt diezelfde middag. Ops ging van een door-een-vendor-gerunde regel op de factuur naar een stuk software dat het team zelf beheert en kan lezen.

Wanneer dit de verkeerde keuze is

Als jouw "workflows" marketing-drips van vijf stappen zijn zonder branching: niet doen. HubSpot is dan de juiste tool. Als je team nul mensen heeft die om 2 uur 's nachts Python kunnen lezen: ook niet doen. Dan ruil je een vendor-probleem in voor een staffing-probleem. Het break-even-punt dat we na dit type project bij vier mid-market klanten zien, ligt ergens rond de zes custom code actions en drie workflows die op elkaars state branchen. Daaronder: blijf in HubSpot. Daarboven: de queue verdient zich binnen een kwartaal terug.

Toen we de plaatsings-automation-worker bouwden voor het Rotterdamse bureau, was idempotentie op de HubSpot write-back het ding dat ons het hardst beet. Dezelfde job die twee keer afging maakte dubbele recruiter-taken aan; recruiters zagen ze, raakten in de war, en piepten ops. We hebben het opgelost door de dedupe_key op de job-tabel ook dienst te laten doen als idempotency-token aan de HubSpot-kant, meegestuurd in de request body, zodat elke retry op dezelfde write uitkomt. Dit soort process automation is tegenwoordig het grootste deel van wat onze studio doet.

Het kleinste wat je vandaag kunt doen, als je naar een HubSpot-workflowscherm staart dat elke maand zwaarder voelt: open de editor, tel de custom code actions, en kijk je laatste drie maandfacturen na op action-overage-regels. Dat aantal en dat bedrag zijn samen de grootte van het probleem.

Kern

Als je zes custom code actions echte branching laat doen in HubSpot workflows, heb je al een job queue. Je hebt 'm alleen nog geen naam gegeven.

FAQ

Moeten we HubSpot helemaal verlaten om dit te doen?

Nee. HubSpot blijft eigenaar van de CRM-records, properties en pipelines. Je vervangt alleen de orkestratielaag die de side-effects afvuurt. Voor de recruiter verandert er niets in de UI.

Hoe groot moet het team zijn voordat dit zich terugverdient?

Ongeveer zes custom code actions en drie workflows die op elkaars state branchen. Daaronder blijven HubSpot workflows de juiste tool. Daarboven verdient een Python-worker zich binnen een kwartaal terug.

Waarom Postgres in plaats van SQS, Celery of een hosted queue?

Eén systeem minder om te hosten, één set credentials minder, één plek minder om te kijken als iets stuk gaat. SELECT FOR UPDATE SKIP LOCKED regelt de locking die tien jaar geleden een aparte queue rechtvaardigde.

Wat gebeurt er met een job die permanent faalt?

Na vijf pogingen gaat hij naar de state dead en blijft daar staan. Een dagelijks rapport laat zien wat in dead zit. Je fixt de handler of de data, en doet daarna een handmatige retry met een UPDATE op één regel.

process automationautomationworkflowintegrationscase studytooling

Iets bouwen?

Start een project