Building a website on the OTYS Web API
Everything you need to connect a recruitment website to OTYS: authentication, reading vacancies, rendering and submitting application forms, receiving webhooks, and running your own cache. No prior knowledge of OTYS is assumed.
What the API is
The OTYS Web API is a REST API that exposes the recruitment data of one OTYS customer — vacancies, recruiters, application forms — so you can build any website you like on top of it.
OTYS is the applicant tracking system (ATS) where the customer's recruiters do their daily work: they publish vacancies, manage candidates and handle applications. Your website is the public face of that data. The API sits in between.
Practically, that means:
- You read vacancies, filters, categories and recruiter profiles.
- You write applications, open applications and job-alert subscriptions.
- You get notified through webhooks when something changes in OTYS.
- You cache everything you read. The API is not a CDN — see Caching.
An API key belongs to a single OTYS customer. Everything you read or write is automatically limited to that customer's data — you never have to filter by customer yourself.
Getting started
Three things to arrange before you write any code: a base URL, an API key, and knowing which website you are building.
Base URL and documentation
| Production base URL | https://webapi.otys.app |
|---|---|
| Interactive reference | https://webapi.otys.app/api/docs — a live Swagger UI where you can authorise with your token and fire real requests |
| This guide | https://webapi.otys.app/api/integration-guide |
| Health check | GET /health-check → {"status":"ok"} (no authentication) |
Every endpoint in this guide is relative to the base URL and starts with /api.
Your API key
The OTYS customer supplies the API key. It is issued inside OTYS and is tied to that customer's account. Treat it like a password: keep it in server-side configuration, never in browser JavaScript or a public repository.
Exchanging the key for a token and calling the API belongs on your backend. If you call it directly from the visitor's browser you expose the key or the token, and you lose the ability to cache — which this API depends on you doing.
Website ID
One OTYS customer can run several websites (for example a main careers site and a campaign site), and a vacancy can be
published on some of them and not others. Most endpoints therefore need a Website-ID header so the API knows
which site you are rendering. The customer tells you which ID to use, or you can look it up with
GET /api/websites.
The value is the numeric ID, or the literal string all to ignore the site filter entirely.
Step 1 — exchange the key for a token
POST /api/auth HTTP/1.1
Host: webapi.otys.app
Content-Type: application/json
{ "key": "YOUR_API_KEY" }
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 50400,
"expires_at": "2026-08-11T02:00:00+00:00"
}
The token is valid for about 14 hours. Cache it on your server and reuse it for every request until
expires_at passes; requesting a new token on every page view is unnecessary and slow.
Refresh a few minutes early to avoid racing the expiry.
Step 2 — call an endpoint
GET /api/vacancies?itemsPerPage=20 HTTP/1.1
Host: webapi.otys.app
Authorization: Bearer <access_token>
Website-ID: 12
Accept-Language: nl
The three headers
| Header | Needed | What it does |
|---|---|---|
Authorization | Always | Bearer <access_token>. Without it you get 401. |
Website-ID | Most reads & all form submissions | Which website context to use. A number, or all. |
Accept-Language | Recommended | Language of the returned content and of validation messages. Falls back to English. |
Supported languages: nl, en, de, fr, es, cs, us,
pl, sk, ro, tr, ru, uk, da.
Regional tags and quality values are understood, so nl-NL,nl;q=0.9,en;q=0.8 works and resolves to nl.
Response format & conventions
The API speaks JSON-LD. You can ignore most of that, but a few conventions will save you time.
Fields that start with @
Responses contain @context, @id and @type. These are machine-readable metadata.
They are harmless — read the normal fields and skip the ones with an @.
Collections and pagination
A list endpoint returns the items under member, with the total and navigation links alongside:
{
"@context": "/api/contexts/Vacancy",
"@id": "/api/vacancies",
"@type": "Collection",
"totalItems": 137,
"member": [ { … }, { … } ],
"view": {
"@id": "/api/vacancies?page=2",
"@type": "PartialCollectionView",
"first": "/api/vacancies?page=1",
"previous": "/api/vacancies?page=1",
"next": "/api/vacancies?page=3",
"last": "/api/vacancies?page=14"
}
}
| Parameter | Meaning |
|---|---|
?page=2 | Which page to return. Starts at 1. |
?itemsPerPage=25 | Page size. Default 10. Vacancies allow up to 100; other collections are capped lower. |
Use totalItems for your result counter and view.next to decide whether to render a "next page" link.
Errors
Errors are JSON with a status and a human-readable detail. Validation problems add a violations array.
| Status | Means | What to do |
|---|---|---|
400 | Malformed request or an invalid filter value | Fix the request; the message says which parameter |
401 | Missing, expired or invalid token | Fetch a new token and retry once |
404 | Not found, or not published on this Website-ID | Render your 404 page |
409 | This candidate already applied to this vacancy | Show a friendly "you already applied" message |
415 | File type not supported on upload | Tell the visitor which formats are allowed |
422 | Validation failed | Show the errors per field — see Application forms |
500 | Something went wrong upstream | Serve cached content if you have it; retry later |
Content negotiation
JSON-LD is the default. Sending Accept: application/json gives you the same data without the
@ metadata, which some HTTP clients find easier to map onto their own models.
When you POST a form, send Content-Type: application/ld+json (or application/json).
Vacancies
The heart of the API: the jobs your visitors come to see.
| Endpoint | Returns |
|---|---|
GET /api/vacancies | Paginated, filterable list |
GET /api/vacancies/{id} | One vacancy by ID |
GET /api/vacancies/slug/{slug} | One vacancy by URL slug |
Build your detail pages on the slug endpoint — it gives you clean, stable URLs such as
/vacancies/senior-developer-amsterdam without keeping an ID in the URL. Both endpoints return the same object.
What a vacancy looks like
{
"id": "1234",
"title": "Senior Developer",
"slug": "senior-developer-amsterdam",
"location": "Amsterdam",
"published": true,
"publishedLanguages": ["nl", "en"],
"entryDateTime": "2026-07-02T09:15:00+02:00",
"lastModified": "2026-08-04T11:42:00+02:00",
"textfields": [
{ "type": "description", "name": "Job description", "value": "<p>As our new…</p>" },
{ "type": "requirements", "name": "Requirements", "value": "<ul><li>5 years…</li></ul>" },
{ "type": "companyProfile", "name": "About us", "value": "<p>We are…</p>" }
],
"matchCriteria": [
{ "id": 1, "name": "Job category", "options": [ { "id": 42, "name": "ICT" } ] },
{ "id": 3, "name": "Education", "options": [ { "id": 71, "name": "Bachelor" } ] }
],
"categories": [ { "id": 5, "name": "Technology" } ],
"salary": { "currency": "EUR", "unit": "month",
"minimumAmount": 4500, "maximumAmount": 6000, "amount": null },
"latitude": 52.3702, "longitude": 4.8952,
"mainImage": { "url": "https://…/photo.jpg", "thumbnailUrl": "https://…/photo-thumb.jpg" },
"gallery": [],
"video": "https://www.youtube.com/watch?v=…",
"relation": { "id": 88, "name": "Acme B.V.",
"logo": { "url": "https://…/logo.png", "thumbnailUrl": "https://…/logo-thumb.png" } },
"showEmployer": true,
"user": { "id": 7, "firstName": "Sam", "lastName": "Jansen",
"jobTitle": "Recruiter", "email": "sam@acme.nl" },
"customApplyUrl": null,
"removeApplyButton": false,
"schemaJobPosting": "{\"@context\":\"https://schema.org/\",\"@type\":\"JobPosting\",…}",
"alternateLinks": { "nl": "https://…/vacatures/senior-developer", "en": "https://…/jobs/senior-developer" }
}
Fields worth knowing
| Field | Use it for |
|---|---|
textfields | The body copy, as an array of typed blocks (description, requirements, company profile, salary text, …). The value is HTML authored by the recruiter — render it as HTML, and style it with your own CSS. |
matchCriteria | Structured properties (job category, education level, hours, contract type…). These are what your facet filters are built from. |
schemaJobPosting | A ready-made schema.org JobPosting document as a JSON string. Drop it into a <script type="application/ld+json"> tag on your detail page and you are eligible for Google for Jobs. Only present on single-vacancy responses. |
alternateLinks | Language code → URL. Feed these into your <link rel="alternate" hreflang="…"> tags. |
customApplyUrl | If set, the recruiter wants applications handled elsewhere — link the apply button there instead of your own form. |
removeApplyButton | If true, hide the apply button entirely. |
showEmployer | If false, the vacancy is confidential — do not display relation.name or the logo. |
user | The recruiter who owns this vacancy. Detail responses only. Nice for a "your contact" block. |
lastModified | Handy as a cache key component and for your XML sitemap. |
Filtering the list
All filters combine, and all of them also work on the facet endpoint described in Search & filters.
| Parameter | Example | Notes |
|---|---|---|
keywords | ?keywords=developer | Full-text over title, descriptions, references and extra fields |
MatchCriteria[{id}] | ?MatchCriteria[1]=42,43 | Criterion 1–18, comma-separated option IDs. Repeat for more criteria. |
VacancyCategory | ?VacancyCategory=5,10 | Comma-separated category IDs |
geo-zipcode, geo-radius, geo-country | ?geo-zipcode=1011AB&geo-radius=25&geo-country=nl | Zipcode and radius are both required for a proximity search. Radius 1–1000 km. Countries: nl, be, lu, fr, de, gb, au, at (defaults to nl). |
publishedLanguage | ?publishedLanguage=nl,en | Only vacancies published in these languages |
published | ?published=true | Publication status |
Note the capital letters in MatchCriteria and VacancyCategory — these parameters are case-sensitive.
Add ?includeUnpublishedTextfields=true to also receive text fields the recruiter has not marked as published.
Each text field then carries a published flag so you can decide what to show. Useful for preview pages;
leave it off for the public site.
Search & filters
How to build a filter sidebar with live result counts, without hard-coding a single option.
| Endpoint | Returns |
|---|---|
GET /api/vacancy_filters | The filters to render, with option counts for the current search |
GET /api/match_criteria | All match criteria and every possible option |
GET /api/vacancy_categories | All vacancy categories |
The recommended approach
Use /api/vacancy_filters and pass it exactly the same query parameters you pass to
/api/vacancies. It returns the filters that make sense for that result set, including a
frequency per option — the number of matching vacancies. That is how you get the
"ICT (23)" counts and how you grey out options that would lead to zero results.
{
"member": [
{
"param": "MatchCriteria[1]",
"name": "Job category",
"type": "matchcriteria",
"options": [
{ "name": "ICT", "value": "42", "type": "option", "frequency": 23 },
{ "name": "Marketing", "value": "51", "type": "option", "frequency": 4 }
]
},
{
"param": "VacancyCategory",
"name": "Category",
"type": "category",
"options": [
{ "name": "Technology", "value": "5", "type": "option", "frequency": 19 }
]
}
]
}
Each filter tells you the query parameter to use in param and the value to send in options[].value,
so your sidebar can be rendered generically. When the visitor ticks "ICT", add MatchCriteria[1]=42 to both calls
and re-render: the vacancy list narrows and the counts update.
/api/match_criteria and /api/vacancy_categories return the complete, unfiltered option lists without counts.
Use them when you want a fixed navigation structure (for example, a "browse by discipline" page) rather than a
search-dependent sidebar.
Recruiters
The people behind the vacancies — for team pages and "your contact" blocks.
| Endpoint | Returns |
|---|---|
GET /api/users | Paginated list of recruiters |
GET /api/users/{id} | One recruiter |
{
"id": 7,
"firstName": "Sam",
"infix": "van der",
"lastName": "Jansen",
"email": "sam@acme.nl",
"jobTitle": "Senior Recruiter",
"linkedIn": "https://www.linkedin.com/in/…",
"photo": { "url": "https://…/sam.jpg", "thumbnailUrl": "https://…/sam-thumb.jpg" },
"phoneNumbers": [ { "number": "+31201234567", "type": "work" } ]
}
Dutch and Belgian surnames often have an infix (van, de, van der). Compose the display
name as firstName + infix + lastName, skipping the infix when it is empty.
The same object appears as user on a single-vacancy response, so a detail page usually needs no extra call.
Application forms
Application forms are configured by the recruiter inside OTYS, not by you. The API hands you a description of the form and you render it. That way, when a recruiter adds a question, your website picks it up without a deploy.
| Endpoint | Purpose |
|---|---|
GET /api/vacancy_application/{vacancyId} | Get the form for a specific vacancy |
POST /api/vacancy_application/{vacancyId} | Submit an application for that vacancy |
GET /api/open_application | Get the open (spontaneous) application form |
POST /api/open_application/{id} | Submit an open application (id is the form's id from the GET) |
The three-step flow
Step 1 — fetch the form
{
"id": 87,
"title": "Application form",
"pages": [
{
"id": 1,
"title": "Your details",
"intro": "",
"questions": [
{
"id": "q4488",
"question": "First name",
"type": "text",
"options": null,
"constraints": [
{ "@type": "NotBlank", "message": "This value should not be blank." }
]
},
{
"id": "q_email_synthetic",
"question": "E-mail address",
"type": "email",
"options": null,
"constraints": [
{ "@type": "NotBlank", "message": "This value should not be blank." },
{ "@type": "Email", "message": "This value is not a valid email address." }
]
},
{
"id": "q4501",
"question": "Do you have a valid driver's licence (B)?",
"type": "radio",
"options": [
{ "value": "9001", "label": "Yes", "kill": false, "killExplanation": null },
{ "value": "9002", "label": "No", "kill": true,
"killExplanation": "A driver's licence is required for this role." }
],
"constraints": [ { "@type": "NotBlank", "message": "This value should not be blank." } ]
},
{
"id": "q4510",
"question": "Upload your CV",
"type": "file",
"options": null,
"constraints": [ { "@type": "NotBlank", "message": "This value should not be blank." } ]
}
]
}
]
}
Rendering it
Loop the pages, loop the questions, and pick an input based on type:
type | Render as |
|---|---|
text, email, tel, url, number, password, date, time, datetime, range | A matching <input> |
textarea | <textarea> |
select, radio | Single choice from options |
multiselect, checkbox | Multiple choice from options; the answer is an array |
file, multifile | File input — see step 2 |
Use constraints for client-side validation so visitors get instant feedback.
NotBlank means required; Email, Regex (with a pattern), Date and
Choice are the other common ones. The server re-checks everything regardless, so client-side validation is
a convenience, never a guarantee.
You may render all pages as one long form, as a wizard, or however you like. The submission is a single request containing all answers from all pages.
Killer answers
Some options carry "kill": true. These are disqualifying answers: the recruiter has decided that a candidate
who picks that option should not be able to apply. If one is submitted, the API rejects the whole application with
422 and the message in killExplanation.
Because the flag is visible in the form, you can also warn the visitor before they submit — for example by showing the explanation as soon as they select the option. That is a nicer experience than a rejection after they have typed everything. Do not rely on it as enforcement; the API decides.
Step 2 — upload files
File questions do not take the file itself. Upload it first and use the returned ID as the answer. See File uploads for details.
Step 3 — submit
Send a flat answers map: the key is the question id exactly as it came from the GET, the value is
the answer. For questions with options, send the option's value — never the label.
Content-Type: application/ld+json
Authorization: Bearer <access_token>
Website-ID: 12
Accept-Language: nl
{
"answers": {
"q4488": "Jane",
"q4489": "Doe",
"q_email_synthetic": "jane.doe@example.com",
"q4501": "9001",
"q4510": "f3a91c0e-4b77-4f2b-9d0a-7c1e5b8a2d44"
},
"metaData": {
"ip": "84.25.11.9",
"referer": "https://www.google.com/",
"gaSessionId": "GA1.1.1234567890.1699999999",
"visitorExternalId": "your-own-visitor-id",
"utmTags": {
"utmSource": "indeed",
"utmMedium": "cpc",
"utmCampaign": "summer-2026"
}
}
}
metaData is optional but recommended: it lets the recruiter see in OTYS where the applicant came from.
Pass the visitor's IP, the referring URL and any UTM parameters you captured on landing.
{
"message": "success",
"data": {
"procedureId": 55123,
"candidateId": 90210,
"isKnownCandidate": false
}
}
isKnownCandidate tells you whether this person already existed in OTYS. Handy for your thank-you page —
a returning applicant does not need to be told an account was created for them.
Handling a rejected submission
{
"status": 422,
"detail": "q4501: A driver's licence is required for this role.",
"violations": [
{ "propertyPath": "q4501",
"message": "A driver's licence is required for this role.",
"code": "KillerAnswer" },
{ "propertyPath": "q4489",
"message": "This value should not be blank.",
"code": "NotBlank" }
]
}
propertyPath is the question ID, so you can attach each message to the right field.
code tells you the kind of problem — KillerAnswer is the disqualifying case and usually deserves
different styling and copy from an ordinary "this field is required".
All problems come back in one response, so you can highlight every field at once. Nothing is stored in OTYS when a submission fails validation — the visitor can correct and resubmit safely.
If this e-mail address already has an application for this vacancy, you get 409. Show a friendly message
rather than an error — the visitor did nothing wrong.
Open applications
The open (spontaneous) application form works identically, with two differences: fetch it from
/api/open_application without a vacancy, and POST to /api/open_application/{id} using the
id from the form you fetched. The e-mail address must not already exist in OTYS; if it does you get a
422 on the e-mail question.
What happens after a successful submission
The API responds as soon as the candidate and application are recorded, then finishes the slower work in the background: confirmation e-mail to the applicant, notification to the recruiter, attaching the uploaded files, creating a candidate portal account, and applying the customer's GDPR retention settings. You do not need to trigger or wait for any of it — and you should not send your own confirmation e-mail unless the customer asks you to, or applicants will get two.
File uploads
CVs, cover letters, certificates and portfolios. Upload first, reference by ID.
POST /api/files HTTP/1.1
Authorization: Bearer <access_token>
Content-Type: multipart/form-data; boundary=----boundary
------boundary
Content-Disposition: form-data; name="file"; filename="cv-jane-doe.pdf"
Content-Type: application/pdf
…binary…
------boundary--
[
{
"id": "f3a91c0e-4b77-4f2b-9d0a-7c1e5b8a2d44",
"name": "cv-jane-doe.pdf",
"mimeType": "application/pdf",
"size": 184320
}
]
The response is always an array. Use id as the answer to the matching file question in your form submission.
To upload several files at once, repeat the field as file[]; you get one entry back per file, in order.
Accepted formats
Documents, spreadsheets, presentations, images and archives are accepted, including:
pdf, doc, docx, odt, rtf, txt, md,
pages, xls, xlsx, ods, csv, numbers,
ppt, pptx, odp, key,
jpg, png, gif, webp, heic, tiff, svg,
zip, rar, 7z, and more.
Executables, scripts and web pages are always refused — including files whose contents are executable but whose name
claims otherwise. A rejected upload returns 415 or a 422 naming the offending file.
Set an accept attribute on your file input and check the size in the browser before uploading, so visitors
find out immediately rather than after a slow upload.
Job alerts
Let visitors subscribe to an e-mail alert for new vacancies matching their interests.
| Endpoint | Purpose |
|---|---|
GET /api/jobalert-form | Get the subscription form |
POST /api/jobalert-form | Create the subscription |
The form has the same shape as an application form — pages, questions, types, options, constraints — so you can reuse the
same rendering code. Submit the same way, with an answers map keyed by question ID.
Technical fields (which website, which language) are filled in automatically from your headers and are not part of the form.
{
"message": "success",
"data": { "hash": "a1b2c3d4e5f6" }
}
A confirmation e-mail goes out to the subscriber automatically, containing the links to activate, manage and delete their
alert. The hash identifies the subscription — store it if you want to reference it later, but you do not need it
for the basic flow.
Activating, pausing and deleting an alert currently happens through the links in that confirmation e-mail rather than through this API. Point subscribers there and you have a complete flow.
Analytics events
Feed vacancy view counts back into OTYS so recruiters can see how their jobs perform.
Authorization: Bearer <access_token>
Website-ID: 12
Content-Type: application/ld+json
{
"action": "view",
"resourceType": "vacancy",
"resourceId": "1234"
}
A successful call returns 204 No Content with an empty body.
| Field | Value |
|---|---|
action | Currently view |
resourceType | Currently vacancy |
resourceId / resourceSlug | One of the two is required. If you send both, the ID wins. |
Call this when a visitor opens a vacancy detail page. Because you will be serving that page from your own cache, fire the event from the request handler — not from the code that fetches the vacancy, or you will only count cache misses. Filter out your own bots and preview traffic so the numbers stay meaningful.
Website configuration
Tell OTYS where your website lives, so the ATS can link back to it — and register your webhook endpoint.
| Endpoint | Purpose |
|---|---|
GET /api/websites | List the customer's websites and their IDs |
GET /api/websites/{id} | Current configuration of one website |
PATCH /api/websites/{id} | Update URL formats and the webhook URL |
URL formats
Recruiters working in OTYS have buttons to open a vacancy on the live site or to preview it. OTYS also multiposts vacancies to job boards using your URLs. For any of that to work, OTYS needs to know how your URLs are built.
Content-Type: application/merge-patch+json
{
"externalUrl": "https://www.your-site.nl",
"externalVacancyDetailUrlFormat": "/vacancies/{{slug}}",
"externalVacancyPreviewUrlFormat": "/vacancies/{{slug}}/preview",
"externalApplyUrlFormat": "/vacancies/{{slug}}/apply",
"webhookUrl": "https://www.your-site.nl/webhooks"
}
| Field | Notes |
|---|---|
externalUrl | Your site's base URL. Must be https. |
externalVacancyDetailUrlFormat | Path to a vacancy page. Must contain {{slug}}, which OTYS replaces with the vacancy slug. |
externalVacancyPreviewUrlFormat | Path used by the preview button, for vacancies not yet public. |
externalApplyUrlFormat | Path to your application form. |
webhookUrl | Where change notifications are sent. Must be https. See Webhooks. |
hostedPageUrl | Read-only. The OTYS-hosted page for this website. |
You normally set this once when the site goes live, and again if your URL structure changes. It is not something your site does on every request.
Setting webhookUrl to null unsubscribes you from all notifications.
Client configuration
One call that tells you how this particular customer is set up, so you can adapt instead of assume.
{
"clientId": 4711,
"clientCode": "ACME",
"clientName": "Acme Recruitment",
"defaultContentLanguage": "nl",
"contentLanguages": ["nl", "en"],
"useVacancySlugs": true,
"gdprEnabled": true,
"gdprEnabledApplication": true,
"gdprEnabledOpenApplication": true,
"gdprAcceptedMonths": 24,
"gdprDeclinedDays": 28,
"onRecruitEnabled": false,
"websites": [ { "id": 12, "name": "Careers site" } ],
"vacancyTextFields": [
{ "name": "Job description", "type": "description", "published": true },
{ "name": "Internal notes", "type": "internal", "published": false }
]
}
Useful when building a site that has to work for more than one customer:
contentLanguages— which language switcher options to render.useVacancySlugs— whether slug-based URLs are available for this customer.websites— the availableWebsite-IDvalues with their names.vacancyTextFields— which text field types exist and which are public, so you can lay out a detail page without hard-coding field names.gdpr*— whether GDPR consent applies and how long data is kept, if you want to reflect that in your privacy copy.
This is stable configuration. Fetch it at build or deploy time, or cache it for hours — it does not change during a visit.
Caching — required
The API does not cache responses for you and sends no cache headers. Every call travels all the way to the ATS. A site without its own cache will be slow, and will stay slow.
A vacancy overview page can easily need a list call plus a filter call. Multiply that by every visitor and every bot, and an uncached site produces thousands of round trips to the ATS for data that changes a few times a day. Build the cache before you launch, not after the first complaint.
What to cache, and for how long
| Data | Suggested TTL | Notes |
|---|---|---|
| Vacancy lists & searches | 5–15 minutes | Cache per unique combination of filters, page, language and Website-ID |
| Vacancy detail pages | 15–60 minutes | Key on slug or ID + language |
| Filters, match criteria, categories | 1–6 hours | Changes rarely |
| Recruiters | 1–6 hours | Changes rarely |
| Client configuration | 6–24 hours | Effectively static |
| Application & job alert forms | 1–5 minutes, or not at all | A recruiter can change questions at any time; stale questions cause failed submissions |
| Access token | Until expires_at | Reuse it; do not re-authenticate per request |
| Form submissions, uploads, analytics | Never | These are actions, not data |
Always include the language and the Website-ID in your cache key. Forgetting either is the classic bug:
Dutch visitors served English content, or one site showing another site's vacancies.
Serve stale rather than fail
Keep a copy of the last good response beyond its normal TTL. If the API is briefly unreachable or returns a
500, serve the stale copy instead of an error page. Visitors will not notice a vacancy list that is twenty
minutes old; they will notice a broken site.
Wiring webhooks into your cache
A TTL alone forces a trade-off: short TTLs mean load, long TTLs mean stale content. Webhooks remove the trade-off. Set a comfortably long TTL and let OTYS tell you the moment something changes.
Which event clears what
| Event received | Suggested action |
|---|---|
vacancyNew | Clear vacancy lists, filters and your sitemap. There is nothing to fetch by ID yet on most sites, so a list refresh is enough. |
vacancyUpdate | Clear the detail entry for that ID (all languages) and the lists. Optionally re-fetch immediately to warm the cache. |
vacancyDelete | Remove the detail entry and clear the lists. Make sure the page now returns 404 or redirects. |
userUpdate | Clear that recruiter and any vacancy detail pages that embed them. |
matchCriteriaUpdate | Clear filters, match criteria and cached search results, since option labels may have changed. |
formUpdate / formDelete | Clear cached application forms. Serving an outdated form leads to submissions that fail validation. |
Keep the webhook handler cheap: purge the relevant keys, return 200, and let the next visitor repopulate.
If you prefer to warm the cache immediately, do it in a background job — do not make the API wait while you re-fetch.
Long TTLs for speed, webhooks for freshness, and a short "safety net" TTL of a few hours so a missed webhook can never leave content stale forever. That combination survives a failed delivery without anyone noticing.
Forcing a refresh
The API keeps a small internal cache of configuration data for your customer. If you ever need to force it to re-read everything from OTYS — after a bulk import, for example — you can clear it:
DELETE /api/cache
Authorization: Bearer <access_token>
This affects only your own customer's data. It is a maintenance tool, not something to call on a schedule, and it does nothing to your cache — that remains yours to manage.
Webhooks
Instead of polling for changes, let OTYS tell you. One HTTPS endpoint on your side and one PATCH to register it.
Registering
Point the webhookUrl of your website at an endpoint you control:
PATCH /api/websites/12
Content-Type: application/merge-patch+json
{ "webhookUrl": "https://www.your-site.nl/webhooks" }
That single call subscribes you to every event below. Changing the URL moves the subscription; setting it to
null removes it. The URL must be https and publicly reachable.
What you receive
Your endpoint gets a POST with a small JSON body:
POST /webhooks HTTP/1.1
Content-Type: application/json
X-Real-Ip: 10.12.0.4
X-Forwarded-For: 10.12.0.4
{
"eventName": "vacancyUpdate",
"identifier": 1234
}
You get what changed and which record — never the record itself. Fetch the current state through the normal API when you need it. This keeps deliveries small and means you always read fresh, language-correct, authenticated data.
The events
| eventName | identifier | Sent when |
|---|---|---|
vacancyNew | Vacancy ID | A vacancy is created |
vacancyUpdate | Vacancy ID | A vacancy is edited, its form is changed, or it is removed |
vacancyDelete | Vacancy ID | A vacancy is removed |
userUpdate | Recruiter ID | A recruiter's profile is edited |
matchCriteriaUpdate | Criterion ID | Match criteria or their settings change |
formUpdate | Form ID | An application form is edited |
formDelete | Form ID | An application form is deleted |
Rules for your endpoint
- Answer fast. Acknowledge with
200and do the real work afterwards. A slow endpoint holds up the sender. - Be idempotent. The same event can arrive more than once. "Purge cache for vacancy 1234" is safe to run repeatedly; "increment a counter" is not.
- Expect more than one event per action. Deleting a vacancy sends both
vacancyUpdateandvacancyDelete. Handle each on its own merits. - Do not assume the record still exists. After
vacancyDelete, fetching that ID returns404. That is the expected outcome, not an error. - There is no retry. If your endpoint is down or errors, that notification is gone. This is why you also keep a safety-net TTL.
- Restrict access if you can. The request carries
X-Real-IpandX-Forwarded-Forwith the sender's address — allowlist it, or use an unguessable path. Since the body contains only an event name and an ID, a spurious call can at worst make you refresh something.
A minimal handler
POST /webhooks
event = json.eventName
id = json.identifier
switch event:
"vacancyNew", "vacancyDelete":
cache.purgePrefix("vacancy-list")
cache.purge("vacancy-" + id)
"vacancyUpdate":
cache.purge("vacancy-" + id) // every language
cache.purgePrefix("vacancy-list")
"userUpdate":
cache.purge("user-" + id)
"matchCriteriaUpdate":
cache.purgePrefix("filters")
cache.purgePrefix("vacancy-list")
"formUpdate", "formDelete":
cache.purgePrefix("form")
return 200 OK // immediately — refill lazily or in a background job
Testing
Point webhookUrl at a request-inspection service (or a tunnel to your development machine) and edit a vacancy
in OTYS. You should see the POST arrive within seconds. Once the shape is familiar, switch the URL to your real endpoint.
Launch checklist
Worth walking through before a site goes live.
Connection
- API key stored server-side only, never shipped to the browser
- Token cached and reused until
expires_at, refreshed slightly early - A
401triggers exactly one re-authentication and retry, not an infinite loop - Correct
Website-IDon every call that needs it Accept-Languageset from the visitor's language, on every call
Content
- Detail pages built on slugs, with
404handling for unknown ones schemaJobPostingoutput in a JSON-LD script tag on detail pagesalternateLinksmapped tohreflangtagsshowEmployerrespected — confidential vacancies never reveal the employercustomApplyUrlandremoveApplyButtonrespected by the apply button- Recruiter names composed with the
infix - Text field HTML rendered and styled, not escaped
Forms
- Every question
typein the table renders, including ones not currently used — a recruiter may add them tomorrow - Option answers send
value, neverlabel 422violations shown per field viapropertyPathKillerAnswershown differently from ordinary validation errors409shown as a friendly "you already applied" message- Files uploaded first, IDs used as answers
- Form definitions cached briefly or not at all
metaDatapopulated with IP, referer and UTM tags
Performance
- Every read cached, with language and
Website-IDin the key - Stale content served when the API is unavailable
- Webhook endpoint live, registered, and purging the right keys
- Safety-net TTL in place in case a notification is missed
- Analytics events fired per page view, not per cache miss
Configuration in OTYS
- URL formats set with
{{slug}}, so the ATS links back correctly webhookUrlregistered overhttps- The customer has confirmed which confirmation e-mails OTYS sends, so you do not duplicate them