Skip to content
Numan Zahid
Get in touch
All work

One jobs API on top of several recruitment systems

Recruitment technology · Product architecture and build

Laravel 12Filament 3REST integrationsOAuth2MySQLPHP 8.3
1Integration a site maintains, whatever the number of upstream systems
2 APIsDifferent auth, dates and pagination, normalised into one contract
6Endpoints, including a cursor delta feed
Starting point
  • A recruitment agency's vacancies can be spread across several applicant tracking systems and job platforms, each with its own API, authentication method, field names, date formats and pagination rules.
  • A site that wants those vacancies on its own pages therefore has to build and maintain a separate integration per source, for what is ultimately the same job data.
  • There was a sync problem underneath that. A site needs an efficient way to find out what has changed since it last asked, which a plain list endpoint cannot answer without handing back the whole vacancy set every time.
What I did
  • Put one layer in between: a central job API that connects to several recruitment systems and presents their vacancies through a single standardised contract, so the integration work happens once in the middle instead of once per site.
  • Each agency holds one or more source connections, and the differences between those systems -- authentication, pagination, field mapping, dates, and behaviour specific to one provider -- are all resolved inside the service before anything reaches the public API.
  • Made the source a property of the connection rather than of the vacancy, and dropped the per-record platform column in a migration, so consumers work with jobs rather than with the system a job came from and nothing downstream can quietly start depending on its origin.
  • Kept the store deliberately dumb. Rows hold what the upstream returned, raw payload included, while normalisation and derived values are computed at the API layer -- so changing how a provider's vocabulary is interpreted never means importing everything again.
  • Exposed a cursor-based delta feed beside the standard endpoints, so a consumer asks for what has changed since its last sync instead of re-reading the whole board.
Measured outcome
  • A site integrates against one API and receives vacancies from several recruitment systems through the same structure.
  • The differences between source platforms stay inside the integration layer, so a consuming site needs no separate authentication, field mapping or sync logic for each one.
  • The API can return Schema.org job markup directly, so a site gets structured job data without building another transformation layer to produce it.
  • A new agency, or an additional source for one that already exists, is configured in the admin rather than requiring its own integration and deployment.
Technical detail
  • Two upstream APIs are wired so far, and they differ enough to be a real test of the shape: one authenticates through an OAuth2 code exchange that yields a rotating session token, the other with a static tenant token; one returns dates as epoch milliseconds, the other as ISO strings; one paginates in batches, the other returns the whole set in a single request. Both land in the same table behind the same public contract.
  • Identity is the tenant, the connection and the upstream record ID taken together. Keyed on the upstream ID alone, the same vacancy arriving through two connections would collide and one would silently overwrite the other.
  • The public resource is grouped by meaning rather than by upstream field name -- lifecycle, intake, location, client, compensation, dates -- so a consumer's parsing code never encodes one vendor's schema, and does not have to change when a second source appears behind it.
  • The API separates lifecycle status from whether a role is accepting applications. They are usually the same and occasionally not, and collapsing them into one boolean loses the case the client cares about most.
  • Scheduled syncs take a cache lock before running. Without one, a slow run and the next scheduled run overlap and both write the same records, which is how duplicate rows appear in systems like this.
  • List and detail responses carry ETags, so a site polling every few minutes gets a 304 rather than a full payload on the many polls where nothing has changed.
  • API keys carry their own permission set, domain and IP allowlists, hourly rate limit and expiry, so revoking one integration never means rotating credentials for the rest.

Got a project?

Email me with where things are now and what you'd like to change. If I'm not the right person for it, I'll tell you, and I'll usually know someone who is.

hello@zahid.dev