· 4 min read
How I reverse-engineer an ATS
Twenty-five recruiting platforms, almost none of them with a usable API. Here's the actual method: capture one real submission, find the state, rebuild the sequence, and make sure one platform breaking never takes the others down.
Tsenta submits 200,000+ job applications a day across Workday, Greenhouse, Lever, Ashby and twenty-odd other applicant tracking systems. People assume there's an integrations team on the other side handing us API keys.
There isn't. Most ATS platforms have no public write API. The ones that do gate it behind an enterprise sales call and a contract that says "no automated applications." So the integrations are built the other way around: from the outside in, by watching what the browser actually does.
This is the method. It's not clever. It's careful.
1. Capture one real submission, end to end
Everything starts with a single honest run. Open the job posting, open the network tab, and fill the application by hand — every field, every step, every "continue" click — until you land on the confirmation page.
You're not looking at the HTML. You're looking at the requests. For a typical Workday flow that's somewhere between 30 and 80 calls: session bootstrap, a form-definition fetch, one request per step, file uploads for the résumé, a final submit. Most of them are noise. Three or four of them matter.
What I record for every request:
- method, path, and query
- every header the client sets itself (not the browser defaults)
- the body, with the field names exactly as sent
- the response, especially anything that looks like an identifier
Save all of it. You'll need the boring ones later when something breaks.
2. Find the state the form carries between steps
This is the actual work. A multi-step application is a little state machine, and the state lives in a handful of values that get minted in one response and echoed back in a later request. Session tokens. Application IDs. CSRF tokens. Hidden _flowKey fields. Sometimes a signed blob that encodes the whole partial application.
The way to find them is mechanical: take every value longer than eight characters in every response, and search for it in every subsequent request. When a value from response 3 shows up in the body of request 7, that's a binding — request 7 depends on request 3.
Do this across the whole capture and you get a dependency graph. Usually it's shallower than you'd fear: one bootstrap call that mints two or three tokens, then a chain of steps that each pass those tokens forward and add one new ID of their own.
The ones this search misses are the interesting ones:
- values shorter than eight characters (page numbers, step indices)
- values that get transformed — base64'd, URL-encoded, wrapped in
Bearer - arithmetic (an
offsetthat'spage × limit) - anything that only appears inside a JSON body rather than a header or query string
Those you find by reading. There's no shortcut.
3. Rebuild the sequence, then break it on purpose
Now replay the chain with your own inputs. First goal: get the same confirmation page without a browser.
Second goal, and this is the one people skip: break it. Remove a header. Send a stale token. Skip step 4. Submit step 5 twice. Post an empty résumé. For every request, you want to know which parts are actually load-bearing and which are just what the browser happened to send.
This matters because the load-bearing set is what you maintain. If a platform ships a redesign that changes fifteen cosmetic parameters but keeps the same three tokens, an integration built on the minimal set keeps working. An integration that replays the full capture verbatim breaks on the first change to anything.
You're not trying to imitate the browser. You're trying to satisfy the server.
4. Isolate the failure
Twenty-five platforms means twenty-five independent things that can break, and they will — usually on a Tuesday, usually because a product team on the other side shipped a new form field.
The rule is simple: an integration is allowed to fail. It is not allowed to fail quietly, and it is not allowed to take a neighbour down with it.
Concretely:
- every platform is its own worker pool, its own queue, its own circuit breaker
- a failed submission is retried on a schedule, not immediately, and never more than the platform would tolerate from a human
- when the failure rate for one platform crosses a threshold, that platform pauses itself and pages a person. The other twenty-four keep running.
- every submission keeps its full request/response trace, so the fix starts from evidence rather than a guess
Fixing a broken integration is an afternoon. The whole point of the architecture is that it's only an afternoon, and it's only one afternoon, not twenty-five.
5. Keep the human in the loop where it counts
There's a version of this that runs everything server-side and reports "applied ✓" at the end. It's simpler to build and it's worse. The first time it gets something wrong — and it will — the user needs to have seen it happen.
So the on-device flow shows every field as it fills. You can stop it mid-submission. Trust is a latency budget you spend on purpose.
The tradeoff in all of this is honest: you are building on something you don't control, and it will change without telling you. The answer isn't to wait for an API that isn't coming. The answer is to build integrations that are small enough to understand, isolated enough to fail alone, and observable enough that fixing them is boring.
An API would be nicer. Waiting for one isn't a strategy.