OTYS Web API · Integration guide

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.
Job seeker browser Your website your templates, your routes your cache your /webhooks endpoint HTTPS OTYS Web API REST · JSON this document OTYS the ATS where recruiters work webhook: "this changed" Reads flow left to right. Change notifications flow back right to left.
One customer per API key

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 URLhttps://webapi.otys.app
Interactive referencehttps://webapi.otys.app/api/docs — a live Swagger UI where you can authorise with your token and fire real requests
This guidehttps://webapi.otys.app/api/integration-guide
Health checkGET /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.

Call the API from your server, not from the browser

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
POST /api/auth HTTP/1.1
Host: webapi.otys.app
Content-Type: application/json

{ "key": "YOUR_API_KEY" }
200 OK
{
  "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

HeaderNeededWhat it does
AuthorizationAlwaysBearer <access_token>. Without it you get 401.
Website-IDMost reads & all form submissionsWhich website context to use. A number, or all.
Accept-LanguageRecommendedLanguage 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"
  }
}
ParameterMeaning
?page=2Which page to return. Starts at 1.
?itemsPerPage=25Page 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.

StatusMeansWhat to do
400Malformed request or an invalid filter valueFix the request; the message says which parameter
401Missing, expired or invalid tokenFetch a new token and retry once
404Not found, or not published on this Website-IDRender your 404 page
409This candidate already applied to this vacancyShow a friendly "you already applied" message
415File type not supported on uploadTell the visitor which formats are allowed
422Validation failedShow the errors per field — see Application forms
500Something went wrong upstreamServe 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.

EndpointReturns
GET /api/vacanciesPaginated, 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

GET /api/vacancies/slug/senior-developer-amsterdam (abbreviated)
{
  "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

FieldUse it for
textfieldsThe 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.
matchCriteriaStructured properties (job category, education level, hours, contract type…). These are what your facet filters are built from.
schemaJobPostingA 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.
alternateLinksLanguage code → URL. Feed these into your <link rel="alternate" hreflang="…"> tags.
customApplyUrlIf set, the recruiter wants applications handled elsewhere — link the apply button there instead of your own form.
removeApplyButtonIf true, hide the apply button entirely.
showEmployerIf false, the vacancy is confidential — do not display relation.name or the logo.
userThe recruiter who owns this vacancy. Detail responses only. Nice for a "your contact" block.
lastModifiedHandy 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.

ParameterExampleNotes
keywords?keywords=developerFull-text over title, descriptions, references and extra fields
MatchCriteria[{id}]?MatchCriteria[1]=42,43Criterion 1–18, comma-separated option IDs. Repeat for more criteria.
VacancyCategory?VacancyCategory=5,10Comma-separated category IDs
geo-zipcode, geo-radius, geo-country?geo-zipcode=1011AB&geo-radius=25&geo-country=nlZipcode 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,enOnly vacancies published in these languages
published?published=truePublication status

Note the capital letters in MatchCriteria and VacancyCategory — these parameters are case-sensitive.

Unpublished text fields

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.

EndpointReturns
GET /api/vacancy_filtersThe filters to render, with option counts for the current search
GET /api/match_criteriaAll match criteria and every possible option
GET /api/vacancy_categoriesAll 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.

GET /api/vacancy_filters?keywords=developer
{
  "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.

EndpointReturns
GET /api/usersPaginated 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.

EndpointPurpose
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_applicationGet 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

1 Fetch the form GET the pages, questions, types, options and validation rules. 2 Upload any files POST /api/files returns an ID per file. Skip if no file fields. 3 Submit the answers POST a map of question ID → answer. File IDs are the values. Never cache step 1 for long, and never cache steps 2 and 3 at all. A recruiter can change the questions at any moment.

Step 1 — fetch the form

GET /api/vacancy_application/1234 (abbreviated)
{
  "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:

typeRender as
text, email, tel, url, number, password, date, time, datetime, rangeA matching <input>
textarea<textarea>
select, radioSingle choice from options
multiselect, checkboxMultiple choice from options; the answer is an array
file, multifileFile 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.

Pages are a hint, not a rule

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.

POST /api/vacancy_application/1234
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.

201 Created
{
  "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

422 Unprocessable Content
{
  "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.

409 Conflict: already applied

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 · multipart/form-data
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--
201 Created
[
  {
    "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.

EndpointPurpose
GET /api/jobalert-formGet the subscription form
POST /api/jobalert-formCreate 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.

201 Created
{
  "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.

Managing existing alerts

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.

POST /api/analytics
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.

FieldValue
actionCurrently view
resourceTypeCurrently vacancy
resourceId / resourceSlugOne 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.

EndpointPurpose
GET /api/websitesList 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.

PATCH /api/websites/12
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"
}
FieldNotes
externalUrlYour site's base URL. Must be https.
externalVacancyDetailUrlFormatPath to a vacancy page. Must contain {{slug}}, which OTYS replaces with the vacancy slug.
externalVacancyPreviewUrlFormatPath used by the preview button, for vacancies not yet public.
externalApplyUrlFormatPath to your application form.
webhookUrlWhere change notifications are sent. Must be https. See Webhooks.
hostedPageUrlRead-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.

GET /api/configuration
{
  "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 available Website-ID values 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.

Treat this as part of the integration, not an optimisation

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

DataSuggested TTLNotes
Vacancy lists & searches5–15 minutesCache per unique combination of filters, page, language and Website-ID
Vacancy detail pages15–60 minutesKey on slug or ID + language
Filters, match criteria, categories1–6 hoursChanges rarely
Recruiters1–6 hoursChanges rarely
Client configuration6–24 hoursEffectively static
Application & job alert forms1–5 minutes, or not at allA recruiter can change questions at any time; stale questions cause failed submissions
Access tokenUntil expires_atReuse it; do not re-authenticate per request
Form submissions, uploads, analyticsNeverThese 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.

Your website OTYS Web API OTYS
READ PATH Visitor opens a page Your cache hit? serve it, done miss OTYS Web API fetch, then store OTYS INVALIDATION PATH Recruiter edits vacancy 1234 OTYS Web API forwards the event Your /webhooks purge keys for 1234 cache entry dropped The next visitor causes one fresh fetch. Everyone after that is served from cache again. Long TTLs stay safe, because changes arrive as events instead of being waited for.

Which event clears what

Event receivedSuggested action
vacancyNewClear vacancy lists, filters and your sitemap. There is nothing to fetch by ID yet on most sites, so a list refresh is enough.
vacancyUpdateClear the detail entry for that ID (all languages) and the lists. Optionally re-fetch immediately to warm the cache.
vacancyDeleteRemove the detail entry and clear the lists. Make sure the page now returns 404 or redirects.
userUpdateClear that recruiter and any vacancy detail pages that embed them.
matchCriteriaUpdateClear filters, match criteria and cached search results, since option labels may have changed.
formUpdate / formDeleteClear 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.

A belt-and-braces setup

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
}
It is a notification, not a data feed

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

eventNameidentifierSent when
vacancyNewVacancy IDA vacancy is created
vacancyUpdateVacancy IDA vacancy is edited, its form is changed, or it is removed
vacancyDeleteVacancy IDA vacancy is removed
userUpdateRecruiter IDA recruiter's profile is edited
matchCriteriaUpdateCriterion IDMatch criteria or their settings change
formUpdateForm IDAn application form is edited
formDeleteForm IDAn application form is deleted

Rules for your endpoint

  • Answer fast. Acknowledge with 200 and 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 vacancyUpdate and vacancyDelete. Handle each on its own merits.
  • Do not assume the record still exists. After vacancyDelete, fetching that ID returns 404. 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-Ip and X-Forwarded-For with 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 401 triggers exactly one re-authentication and retry, not an infinite loop
  • Correct Website-ID on every call that needs it
  • Accept-Language set from the visitor's language, on every call

Content

  • Detail pages built on slugs, with 404 handling for unknown ones
  • schemaJobPosting output in a JSON-LD script tag on detail pages
  • alternateLinks mapped to hreflang tags
  • showEmployer respected — confidential vacancies never reveal the employer
  • customApplyUrl and removeApplyButton respected by the apply button
  • Recruiter names composed with the infix
  • Text field HTML rendered and styled, not escaped

Forms

  • Every question type in the table renders, including ones not currently used — a recruiter may add them tomorrow
  • Option answers send value, never label
  • 422 violations shown per field via propertyPath
  • KillerAnswer shown differently from ordinary validation errors
  • 409 shown as a friendly "you already applied" message
  • Files uploaded first, IDs used as answers
  • Form definitions cached briefly or not at all
  • metaData populated with IP, referer and UTM tags

Performance

  • Every read cached, with language and Website-ID in 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
  • webhookUrl registered over https
  • The customer has confirmed which confirmation e-mails OTYS sends, so you do not duplicate them