Process automation
Make.com to Temporal: rebuilding a recruiter's bot mesh
Six Make.com scenarios, one ops lead waking up at 06:00 to a Bullhorn token nobody refreshed. Here is how we collapsed it all into one Temporal workflow.

At 06:11 on a Tuesday in March, the operations lead at a sixteen-person recruitment firm in Eindhoven opened her phone to the same Telegram alert she had gotten the Tuesday before. A Make.com scenario had stopped at step seven of fourteen. The Bullhorn access token had expired mid-run, the retry route had fired without rotating the refresh token, and twelve candidate records were now sitting in a queue that nobody owned. She fixed it from her kitchen in twenty minutes. Then she emailed us.
What follows is the playbook we used to replace six Make.com scenarios with one Temporal workflow and a Hatchet worker pool. It is not a Make.com hit piece. Make is a good fit for two-step automations a non-engineer can build on a Sunday afternoon. It is a poor fit for what this team had grown into.
The six-bot inventory we inherited
Before we touched anything, we listed every scenario and every place it stored state. The team had:
- A resume intake bot pulling from a Workable webhook and posting to Bullhorn.
- A LinkedIn Recruiter outreach bot that ran InMail templates against a saved search.
- A candidate scoring bot calling an LLM on parsed CV text and writing a custom Bullhorn field.
- An interview scheduler bridging Cal.com and Outlook calendars across two seat licenses.
- A weekly client report generator pulling Bullhorn stats into Notion and exporting a PDF.
- A GDPR compliance scrubber sweeping the database for right-to-be-forgotten requests.
There was also a seventh scenario nobody talked about. It refreshed the Bullhorn token every nine minutes and wrote the new value to a Google Sheet that the other six scenarios read from. That sheet was the single point of failure for the entire stack. If anyone edited a cell by mistake, every bot stalled.
Where Make.com cracked at this scale
Three pressures were stacking up. The first was state. Six independent scenarios meant six independent retry policies, six error routes, six ways to lose a record halfway through. There was no parent process anywhere that could say "this candidate is being processed, do not start a second pass."
The second was Bullhorn's auth model. The Bullhorn REST API issues short-lived access tokens (the default lifetime is ten minutes) and rotates the refresh token on every exchange. If two scenarios race to refresh at the same moment, one of them gets back an invalidated refresh token and is permanently locked out until a human pastes a fresh one. The Google Sheet was meant to prevent that race. It mostly did. Mostly was the problem.
The third was the LinkedIn Recruiter weekly quota. Each seat license has a fixed InMail budget that resets on the seat's billing day, not on a global Monday. The outreach bot tracked usage by counting rows in a Google Sheet. The count was always off by a few because of failed sends that were never reconciled. When the quota ran out, the bot got 429s and the scoring bot, which sat downstream, started writing empty scores to Bullhorn.
Operation cost on the Make side was about €420 per month and rising. The technical debt was higher than that.
The Temporal and Hatchet split
We picked two open-source engines that share a Postgres instance and play different roles.
Temporal handles the long-running, durable, stateful pieces. A single workflow per candidate, written in TypeScript, lives for as long as the candidate is active. It survives worker restarts, version upgrades, and process kills. Token brokers and rate-limit governors live as separate long-running workflows that other workflows query for current state.
Hatchet runs the per-event fan-out: resume parsing, LLM scoring, PDF rendering, webhook receipt acks. It is faster to start than a Temporal workflow and cheaper to run for a one-shot task that fits in under thirty seconds. Hatchet ships with its own Postgres-backed queue, so we did not need Redis or RabbitMQ.
One Postgres database, two services, one set of backups. The whole control plane runs on a single Hetzner VPS for under €40 a month.
Surviving the Bullhorn token refresh
The single change that delivered the most relief was moving Bullhorn auth into a dedicated Temporal workflow. We call it the token broker.
import {
proxyActivities, sleep, continueAsNew,
defineQuery, setHandler,
} from '@temporalio/workflow';
import type * as activities from './activities';
const { refreshBullhornToken } = proxyActivities<typeof activities>({
startToCloseTimeout: '60 seconds',
retry: { maximumAttempts: 8, initialInterval: '5s', backoffCoefficient: 2 },
});
export const getAccessToken = defineQuery<string>('getAccessToken');
type TokenState = { accessToken: string; refreshToken: string };
export async function bullhornTokenBroker(state: TokenState): Promise<void> {
setHandler(getAccessToken, () => state.accessToken);
for (let i = 0; i < 200; i++) {
state = await refreshBullhornToken(state.refreshToken);
await sleep('9 minutes');
}
await continueAsNew<typeof bullhornTokenBroker>(state);
}
Every other workflow that needs to call Bullhorn issues a Temporal query against the broker for the current access token. Queries are cheap and serialised by the workflow engine, so there is no race. The broker is the only process that ever touches the refresh token, so there is no one to race with.
The continueAsNew call after 200 cycles is housekeeping. Temporal histories are bounded, so we close one workflow run and start a fresh one with the same state every thirty hours or so. The caller does not notice.
Anything that rotates on every use (refresh tokens, single-use API keys, OAuth PKCE codes) needs exactly one owner. If two processes can race, one of them will eventually win and lock you out.
The LinkedIn Recruiter weekly reset
The InMail quota lives in another long-running workflow we call the seat governor. There is one per LinkedIn Recruiter seat. It holds the remaining quota as workflow state, exposes a reserveInmail update that returns true if a credit was reserved, and listens for a weekly reset signal.
import {
defineUpdate, defineSignal, defineQuery,
setHandler, condition,
} from '@temporalio/workflow';
export const reserveInmail = defineUpdate<boolean>('reserveInmail');
export const resetQuota = defineSignal<[number]>('resetQuota');
export const remaining = defineQuery<number>('remaining');
export async function linkedinSeatGovernor(weeklyQuota: number): Promise<void> {
let credits = weeklyQuota;
setHandler(reserveInmail, () => {
if (credits > 0) { credits -= 1; return true; }
return false;
});
setHandler(resetQuota, (n) => { credits = n; });
setHandler(remaining, () => credits);
await condition(() => false); // run until the seat is decommissioned
}
The reset is fired by a Temporal Schedule, one per seat, on a cron that matches the seat's reset day. No Google Sheet, no counter drift, no scoring bot writing nulls when the quota dies.
When a worker wants to send an InMail, it issues the update and waits on the boolean reply. If the answer is no credit, the work item parks in a Temporal timer until the next Monday and resumes automatically. The ops lead never sees it.
Migration order and rollback
We moved scenarios in this order. The sequence matters more than the technology choice.
- Token broker first. This was the load-bearing fault. Until it was solved, every other migration was at risk of inheriting the same bug.
- Resume intake second. Highest volume, lowest blast radius if a duplicate slipped through. We added an idempotency key based on candidate email plus inbound timestamp, so the same record could be replayed safely.
- LinkedIn outreach third. Required the seat governor to be in place. We ran it in shadow mode for four days: the new workflow logged what it would have sent, while the Make scenario kept running. Once the two agreed on quota and send list for three days running, we cut over.
- Scoring and scheduler in parallel. These touched separate Bullhorn fields and separate calendars, so concurrent migration was safe.
- Weekly report and GDPR scrubber last. Low frequency, easy to verify by inspection.
The rollback plan was boring on purpose. Each Make.com scenario stayed in place as a cold standby for seven days after its Temporal replacement went live. Cutover meant disabling the Make scenario, not deleting it. If anything broke in the first week, we flipped the toggle and the old version took over within five minutes.
Do not migrate the GDPR scrubber first. It deletes records by design. If the new workflow has an off-by-one in its query, you discover it by losing a candidate.
Outcomes for the team
Three numbers tell the story. The 06:00 pages went from roughly one per fortnight to zero in the last nine weeks. The monthly bill dropped from about €420 on Make plus a paid Google Workspace seat used as integration glue, to €38 for the Hetzner VPS and €0 for the Postgres on it. Adding a new integration (we shipped a Greenhouse connector two weeks after go-live) went from "schedule a Make rebuild and warn everyone" to one afternoon, one pull request, one workflow file.
The less visible change was that the operations lead stopped being a Make.com administrator. Her job is recruiting operations, not babysitting scenario runs. The pipeline now fails loudly into a single dashboard when something genuinely needs a human, and it fails quietly into a retry timer when something does not. That distinction was impossible to enforce inside a sprawl of independent scenarios.
None of this requires Temporal specifically. Cadence, Restate, Inngest, and a careful homegrown Postgres queue would each get you close. What the choice does require is one person on the team who is comfortable reading a workflow file and stepping through a replay. If nobody on staff or on call can do that, you are trading one sprawl for another.
The five-minute audit
When we rebuilt this pipeline for the Eindhoven recruiter, the thing that surprised us was how much of the work was not orchestration but token hygiene. We ended up writing more about session brokers than about workflows, which is why our process automation engagements now start with an auth audit before anything else.
Open every automation tool your team owns. For each one, find the place where the auth tokens live. If it is a spreadsheet, a Notion page, or a comment in a scenario, that is your token broker today. Decide who owns it before next Tuesday at 06:00.
Key takeaway
Anything that rotates on every use (refresh tokens, single-use keys, PKCE codes) needs exactly one owner, or you eventually lock yourself out.
FAQ
Why Temporal and Hatchet instead of just one engine?
Temporal handles long-lived, stateful workflows like token brokers and rate-limit governors. Hatchet runs faster, cheaper one-shot tasks like resume parsing. One Postgres, two engines, clear roles.
Does the same playbook work for HubSpot or Salesforce instead of Bullhorn?
Yes. Any OAuth provider that rotates refresh tokens on use needs the same single-owner broker pattern. HubSpot, Xero, and Salesforce all behave the same way under concurrent load.
How long did the migration take end to end?
About six weeks for the sixteen-person team. Three weeks of scoped rebuild, one week of shadow runs, two weeks of staggered cutover with the Make scenarios kept warm as fallback.
Can a non-engineer maintain this once it is live?
Day-to-day yes, through the Temporal Web UI and the dashboard. Changes to workflow code still need someone who reads TypeScript and can run a replay test before deploy.