All pages

Setup

Careers page and embed

Your public careers page and its slug, plus the embed script — every data- attribute, the fallback, the inline apply form and the public JSON feed.

Every workspace gets one public careers page; nothing turns it on, and publishing a job lists it. The same openings can also render on your own site from two lines of HTML, or from a public JSON feed you render yourself.

Your careers page address#

text
https://app.krinoa.com/careers/acmehttps://app.krinoa.com/careers/acme/ENG7K2

acme is your careers slug, the Careers URL slug field under Settings › Careers page › Address — change it and every link you have already shared stops working. ENG7K2 is the job's public id, the same handle the app, the CLI and candidate emails use. Both pages are public and crawlable, need no account to read or to apply, and appear in /sitemap.xml — one URL per workspace with something published, plus one per job.

Branding the careers page#

Settings › Careers page › Look holds the branding for the careers page and for the emails candidates receive: the logo (PNG, JPG or SVG, up to 1 MB), the Brand color (#rgb or #rrggbb) and the About paragraph (up to 2000 characters). The brand colour is also the embed's default accent.

Paste the snippet into your site#

Put these two lines where the list should appear, with acme replaced by your careers slug:

html
<div data-krinoa-jobs="acme">  <a href="https://app.krinoa.com/careers/acme">See our open jobs</a></div><script src="https://app.krinoa.com/embed.js?v=2" async></script>

The script runs on DOMContentLoaded (or immediately, if the page has already loaded), finds every element with a data-krinoa-jobs attribute, fetches that workspace's published jobs, and replaces the element's contents with the list. It renders into your page's DOM — not an iframe — so your stylesheet applies to it and search engines see the jobs as part of your page. There is no build step, no framework and no global; the script sets one <style> element and nothing else. embed.js is served with Cache-Control: public, max-age=3600, stale-while-revalidate=86400, and the ?v= is how you force a refresh: bump it and browsers fetch the new file immediately.

Every attribute#

AttributeDefaultWhat it does
data-krinoa-jobsRequired. Your careers slug. Its absence means the element is skipped.
data-appThe origin the script was served fromThe Krinoa origin to read the jobs from. Set it when you serve embed.js from a proxy or a CDN on another host.
data-applyunset"inline" adds an Apply button per job that opens the form in place. Any other value is ignored.
data-srcunsetAttribution. Appended as ?src=… to every job link and to the inline form, and stored on the application.
data-href-patternunsetWhere a job title links. {publicId} and {slug} are substituted; the result is used verbatim, so a relative path stays relative to your site.
data-emptyNo open jobs right now.The line shown when nothing is published.

All of them go on the <div>, not on the <script>. A page may carry more than one list — one element per slug, or the same slug twice with different link patterns — and each mounts independently.

What a reader sees when the feed fails#

Whatever you put inside the <div> stays there until the feed answers, and stays there if it never does. While the fetch is in flight the script appends a small "Loading…" paragraph rather than replacing anything. On failure it removes that paragraph, leaves your content untouched, logs [krinoa] could not load the jobs feed once to the console, and leaves the element unmounted so a later load can try again.

A plain link to your careers page is the fallback worth writing: it is what a reader with JavaScript off, and a crawler after a failed fetch, will see.

Styling the list#

The script injects one stylesheet, once per page, with no reset, no font and no colour of its own. Text is currentColor, so the same sheet reads on a dark site and a light one. Four custom properties are set on the container, and your stylesheet can override any of them:

css
[data-krinoa-jobs] {  --krinoa-accent: #0f766e;  /* hover colour; defaults to your brand colour */  --krinoa-fg: #111;         /* list text; defaults to the page's own colour */  --krinoa-muted: #666;      /* the meta line and the Apply verb */  --krinoa-border: #e5e5e5;  /* the rule between rows */}

--krinoa-accent starts as the Brand color, read from the public workspace feed, and falls back to #e2663f when you have not set one; if that read fails, the list still renders. For finer control the class names are .krinoa-jobs (the <ul>), .krinoa-job (one job) and, inside it, .krinoa-job__title, .krinoa-job__dept, .krinoa-job__meta, .krinoa-job__go (the Apply link), .krinoa-job__apply (the inline Apply button), .krinoa-job__arrow and .krinoa-job__frame (the inline form's iframe), plus .krinoa-jobs__empty and .krinoa-jobs__loading.

Open the apply form in place#

data-apply="inline" turns each row's Apply into a button that opens the real apply form under that row, in an iframe served by Krinoa. The form is framed rather than rendered into your page because the file upload, the multipart submission and the bot challenge all belong to the origin that will store the application — your page never handles a résumé.

One form is open at a time, and the button becomes Close while it is. The framed page sizes itself: it posts { type: "krinoa:height", height } to the parent whenever it grows, and the script resizes the iframe. It only trusts a message whose source is a frame it created, from the origin that frame was loaded from, and ignores heights above 20,000 pixels.

To put a single job's form on its own page, frame it directly — replace ENG7K2 with the job's public id:

html
<iframe  src="https://app.krinoa.com/careers/acme/ENG7K2/apply?embed=1&src=website"  title="Apply"  style="width:100%;border:0;min-height:640px"></iframe>

That page has no header, no footer and no Krinoa chrome, and is marked noindex because the job page is the canonical one. With no embed.js on the page nothing acts on the height messages, so give the frame a min-height you are happy with. Framing is allowed only on the public surfaces — /careers/**, /embed.js, the candidate scheduling links under /i/**, the public feed and the discovery files.

The résumé comes first#

The apply form asks for the résumé before anything else. When someone picks a file, it uploads straight away and the form fills in what it can read from the first page — name, email, phone, LinkedIn, GitHub, portfolio — with the line "Filled from your résumé — please check." underneath.

Three things are worth knowing about that step:

  • It is a suggestion, not a parse. A handful of regular expressions read the first 4,000 characters of the text layer. No model runs, nothing is inferred, and a scan with no text layer simply fills nothing. Only empty boxes are filled — anything already typed wins — and your own custom questions are never touched.
  • Nothing is stored against a person until Send. The early upload creates a file and a note of the file, and no candidate, no application and no email. Pressing Send is what turns it into an application; the file becomes that candidate's résumé as it stands, with no second copy.
  • An upload nobody sends is deleted within the hour. The housekeeping sweep removes unclaimed files and their rows, so an abandoned form leaves nothing behind.

If the upload fails for any reason — a flaky connection, a file we refuse, a busy moment — the form says nothing and falls back to sending the file with the application, exactly as it always has. The same path works inside the embedded iframe. The résumé is still checked the same way on the way in: PDF or DOCX, 5 MB, and the bytes have to be what the name says.

Where an application came from#

Applications from the embedded form record their source as embed, ones from the hosted careers page careers, and a visit carrying a valid ?via=<memberId> referral link (from Settings › Team) referral. A ?ref=<token> on the URL is the other kind: the single-use token behind an emailed referral invite, which also records referral and, unlike ?via=, ties the application back to the invitation it came from. If you post an application from a server rather than from the framed form, send the header x-krinoa-embed: 1 to record the embed source. data-src is the free-text label beside it: it rides along as ?src= on every job link and on the inline form, and is stored verbatim (up to 80 characters), enough to tell your homepage from a conference microsite. It works on a careers URL too — …/careers/acme?src=hn-whoishiring.

The public JSON feed#

Three GET endpoints answer with no credential at all, and answer cross-origin, which is what lets the script run on your domain:

text
GET /api/v1/public/{orgSlug}GET /api/v1/public/{orgSlug}/jobsGET /api/v1/public/{orgSlug}/jobs/{jobPublicId}

They carry Access-Control-Allow-Origin: *, allow GET and OPTIONS, and answer the preflight. Nothing else on /api/v1 does. Read the feed from a browser or your own server and render whatever markup you like.

  • The org endpoint is the careers page header: id, name, slug, logoUrl, about, brandColor. logoUrl is null when no logo is set and is a signed absolute URL on S3-compatible storage; about and brandColor are null until they are filled in.
  • The jobs endpoint returns every published job, newest first: id, publicId, title, department, location, employmentType, shortDescription, publishedAt. employmentType is one of full_time, part_time, contract or internship; department, location, shortDescription and publishedAt may be null.
  • The one job endpoint adds description, requirements (an array of strings, or null), compensation, form and whatsappAvailable. form is always resolved — a job that was never customised returns the built-in default — and holds fields (the built-in field keys, their kind and whether they are required) and questions (id, label, kind, options). An unknown slug or an unpublished job answers 404.

Applying is the one public write and is deliberately not cross-origin: POST /api/v1/public/{orgSlug}/jobs/{jobPublicId}/apply takes multipart/form-data and no CORS headers, which is why the inline form is an iframe. See API for the request shape.

Updated 2026-09-16