Before you start
There are two ways to integrate. They share one partner record, one public key, one sign-in token and the same limits.
- The hosted embed: one script tag, or the same URL in an app web view. Our components with your accent colour and language. Sign-in, consent, onboarding and "express interest" happen inside the frame.
- The headless API: JSON endpoints called with your key, and you build the interface. Jobs, sign-in, express interest and the AI onboarding chat are available.
| Credential | Where it lives | What it does |
|---|---|---|
Public key pk_… | your pages and apps; it is visible in page source | identifies you: configuration, attribution, scope and rate limits |
| Server secret (64 hex characters) | your backend only | signs sign-in tokens and reads stats; after a rotation the old one keeps working for 10 minutes |
Jobyhood also registers your allowed origins (https://news.example), which decide who may frame the embed and call the API from a browser; a default country and optional default filters; your theme; whether sign-in is enabled; and whether Jobyhood trusts your email verification, which account creation needs.
Ask for a partner record through the form on the Partners page. Your team then manages keys, websites, theme and members in the partner dashboard.
Hosted embed
Paste the loader where the jobs should appear. It inserts an iframe there and keeps its height in sync with the content.
<script src="https://jobyhood.com/embed/v1.js"
data-key="pk_your_public_key" data-lang="bg" data-theme="auto"
data-accent="#0C68E9" data-filters="city=Sofia" async></script>
<script>
window.addEventListener('load', () => {
// {kind}; matches_available also carries {count} (a number only).
JobyhoodJobs.onEvent = (event) => {
if (event.kind === 'matches_available') showBadge(event.count);
};
// "Continue with …" and no token yet: mint one on your server, then hand it over.
JobyhoodJobs.onNeedsToken = async () => {
const response = await fetch('/jobyhood-token', { credentials: 'include' });
const { token } = await response.json();
JobyhoodJobs.setToken(token);
};
});
</script>| Attribute | Values | Notes |
|---|---|---|
data-key | your public key | required |
data-lang | bg, en | default: the visitor's browser language |
data-theme | light, dark, auto | auto follows the visitor's system setting |
data-accent | #rrggbb | buttons and highlights |
data-radius | 0–32 | corner radius in pixels |
data-country | a two-letter code, such as BG | the country of the postings |
data-filters | q=…&role=…&city=…&country=… | the first filters; the visitor can still search |
data-job | a posting id | opens that posting first |
data-token | a sign-in token | signs the visitor in on load (Sign-in token) |
data-on-event | the name of a global function | called like onEvent |
JavaScript API
| After load | What it does |
|---|---|
JobyhoodJobs.setToken(token) | hands a sign-in token to the frame |
JobyhoodJobs.open(jobId) | opens one posting |
JobyhoodJobs.setFilters({city: "Varna"}) | changes the filters of the list (q, role, city, country) |
JobyhoodJobs.onEvent = (event) => … | receives every event below |
JobyhoodJobs.onNeedsToken = () => … | called when the visitor taps "Continue with {your name}" and no token was given; answer with setToken |
Events
kind | When |
|---|---|
signup | an account was created through your sign-in |
login | an existing Jobyhood user signed in through you |
interest | the visitor expressed interest in a posting |
needs-token | the frame asked for a sign-in token (onNeedsToken is called too) |
matches_available | the match count of a signed-in member is known or changed; it carries count and nothing else |
Events carry no personal data: no names, emails, job ids or scores.
Your own iframe
Without the loader, frame https://jobyhood.com/embed/jobs yourself and talk to it with postMessage. The frame posts jobyhood:resize {height}, jobyhood:event {kind, count} and jobyhood:needs-token to the page that frames it. It accepts jobyhood:token {token} and jobyhood:filters {filters} only from that page, and only on one of your registered origins.
// <iframe id="jobyhood" src="https://jobyhood.com/embed/jobs?key=pk_your_public_key&lang=bg"
// title="Jobs" style="width:100%;border:0"></iframe>
const JOBYHOOD = 'https://jobyhood.com';
const frame = document.getElementById('jobyhood');
window.addEventListener('message', async (event) => {
if (event.origin !== JOBYHOOD || event.source !== frame.contentWindow) return;
const message = event.data || {};
if (message.type === 'jobyhood:resize') {
frame.style.height = `${Math.max(240, Math.ceil(message.height))}px`;
} else if (message.type === 'jobyhood:event') {
track(message.kind, message.count);
} else if (message.type === 'jobyhood:needs-token') {
const { token } = await fetch('/jobyhood-token', { credentials: 'include' }).then((r) => r.json());
frame.contentWindow.postMessage({ type: 'jobyhood:token', token }, JOBYHOOD);
}
});
// Later, to change what the list shows:
frame.contentWindow.postMessage({ type: 'jobyhood:filters', filters: { city: 'Varna' } }, JOBYHOOD);Your matches
A visitor signed in through you sees "Your matches" above the job list. It opens /embed/matches, which you can also open directly with your key. It lists the jobs Jobyhood matched to them that your embed can show (open postings, within your company scope if you have one), newest first, without scores.
The list stays in Jobyhood's frame. Your page receives only {kind: "matches_available", count}, once the count is known and again when it changes, so you can show a badge. No job ids, titles or scores reach you.
Sign-in token
Your backend signs a short-lived JWT (HS256, with your server secret) for a user it has already authenticated, and your page or app passes it to Jobyhood. Mint it right before the hand-off: it lives at most 10 minutes and works once.
| Claim | Rule |
|---|---|
iss | your partner slug |
aud | "jobyhood" |
sub | your stable user id, up to 255 characters |
iat, exp | at most 600 seconds apart, with 60 seconds of clock leeway |
jti | unique per token; each token works once |
email | required |
email_verified | true; needed for account creation, and Jobyhood must trust your verification |
given_name, family_name, phone, locale | optional |
import crypto from 'node:crypto';
import jwt from 'jsonwebtoken';
// GET /jobyhood-token, for your signed-in user only. The secret never leaves your server.
app.get('/jobyhood-token', requireLogin, (req, res) => {
const token = jwt.sign(
{
sub: String(req.user.id),
email: req.user.email,
email_verified: true,
given_name: req.user.firstName,
locale: 'bg',
jti: crypto.randomUUID(),
},
process.env.JOBYHOOD_SERVER_SECRET,
{ algorithm: 'HS256', issuer: 'your-partner-slug', audience: 'jobyhood', expiresIn: '5m' }
);
res.set('Cache-Control', 'no-store').json({ token });
});import os
import time
import uuid
import jwt # PyJWT
def jobyhood_token(user) -> str:
"""A single-use sign-in token for a user you have already authenticated."""
now = int(time.time())
claims = {
"iss": "your-partner-slug",
"aud": "jobyhood",
"sub": str(user.id),
"email": user.email,
"email_verified": True,
"given_name": user.first_name,
"locale": "bg",
"iat": now,
"exp": now + 300,
"jti": str(uuid.uuid4()),
}
return jwt.encode(claims, os.environ["JOBYHOOD_SERVER_SECRET"], algorithm="HS256")The embed takes the token from data-token, from a jobyhood:token message of your page or, in an app, from setToken. A #token= in the address counts only inside your frame or your bridged app, never in a plain browser tab. Headless, you exchange it yourself: show the consent screen first and send its version.
POST https://api.jobyhood.com/v1/partners/sso/
X-Jobyhood-Partner-Key: pk_your_public_key
Content-Type: application/json
{"token": "<jwt>", "consent_version": "<consentVersion from config>"}
200 {"access": "…", "refresh": "…", "created": true, "needsOnboarding": true,
"user": {"id": 1, "email": "…", "firstName": "…"}}
400 {"code": "consent_required"}Refusals
| Code | HTTP | What to do |
|---|---|---|
invalid_token, expired | 400 | mint a new token |
replayed | 409 | mint a new token; each jti works once |
consent_required | 400 | show the consent screen with the current consentVersion and retry |
email_not_verified | 403 | the email is not verified, or Jobyhood does not trust your verification yet: offer sign-up on jobyhood.com |
email_in_use | 409 | the email already has a Jobyhood account: link it; the token is not spent |
email_mismatch | 400 | link/ only: the signed-in user's email differs from the token's |
account_linked_elsewhere | 409 | this sub is connected to another Jobyhood profile |
company_account, account_unavailable | 403 | link/ and returning sign-ins: the account is a company or a blocked one (sso/ answers email_in_use for any existing account, a company one included) |
sso_disabled | 403 | contact Jobyhood |
idp_unavailable | 502 | OpenID Connect only: your discovery document or keys cannot be read |
OpenID Connect instead
If your backend cannot mint HS256 tokens but you run or use an OpenID Connect provider, Jobyhood can accept that provider's RS256 id_token in the same token field of sso/ and link/. Jobyhood registers your issuer URL and the client id the token must be issued to.
issis your issuer andaudyour client id.iatandexp: valid now and issued within the last 600 seconds, with 60 seconds of leeway. Request a fresh token for the hand-off.nonceorjtiis required, and the token works once.emailis required, andemail_verifiedmust be the booleantrue.given_name,family_name,phone_numberandlocaleare optional.- The discovery document must name the same issuer. It and its
jwks_urimust be public https addresses, and redirects are not followed.
Everything after verification is the same: consent, single use, the account rules and the response. Your config shows oidcIssuer once this is set up.
Consent
Before an account is created through you, the person sees this, in their language, with links to the Terms and the Privacy Policy and a checkbox:
The embed shows it for you. Headless, you show it yourself and send the consentVersion from config/ as consent_version. People can disconnect you at any time in their Jobyhood settings.
Linking an existing account
When the email already has a Jobyhood account, sso/ answers email_in_use and does not spend the token. In the embed the visitor taps "Sign in to Jobyhood and connect": a Jobyhood sign-in pop-up opens, the person signs in and confirms, and the frame attaches your identity to that account. Nothing about the account reaches your page.
Headless, have the person sign in on jobyhood.com, then call link/ with their Jobyhood access token and the same token, while it is still valid. The emails must match, or the answer is email_mismatch.
POST https://api.jobyhood.com/v1/partners/link/
X-Jobyhood-Partner-Key: pk_your_public_key
Authorization: Bearer <the user’s Jobyhood access token>
Content-Type: application/json
{"token": "<the same jwt>", "consent_version": "<consentVersion from config>"}Mobile apps
Apps open the embed itself, full screen, in a web view. No page of yours is needed: the app talks to the embed through a small bridge it injects.
Open the embed
Load the embed URL with your key and the language. If you already hold a token when the view opens, put it in the fragment: the fragment never reaches a server, a log or a Referer header, and the embed reads it once and removes it from the address. A token in the query string is ignored.
https://jobyhood.com/embed/jobs?key=pk_your_public_key&lang=bgThe bridge: events and a token on demand
Inject JobyhoodApp on Android (addJavascriptInterface, with a postMessage(String) method) or a message handler named jobyhood on iOS, reached as webkit.messageHandlers.jobyhood. When the embed is the top page and finds the bridge, it sends the app the messages a host page would get, as JSON text:
| Message | When |
|---|---|
jobyhood:needs-token | the visitor tapped "Continue with …" and the embed has no token yet |
jobyhood:event {kind} | signup, login or interest happened in the embed |
jobyhood:event {kind, count} | matches_available: how many matches a signed-in member has, for a badge |
Answer jobyhood:needs-token by evaluating JobyhoodJobs.setToken(token) in the web view; it returns true when the token was taken. The function exists only in this case, the embed as the top page with a bridge: a framed embed takes tokens from its host page alone. There is no jobyhood:resize, because the web view is the page. Without a bridge the embed sends nothing, and "Continue with …" works only with a token from the URL.
class JobsActivity : AppCompatActivity() {
private lateinit var web: WebView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
web = WebView(this).also(::setContentView)
web.settings.javaScriptEnabled = true
web.settings.domStorageEnabled = true // the embed keeps its session here
web.settings.setSupportMultipleWindows(true) // the "connect your account" pop-up
web.addJavascriptInterface(Bridge(), "JobyhoodApp") // events; e-mail-only sign-in in the pop-up
web.webChromeClient = Popups()
web.loadUrl("https://jobyhood.com/embed/jobs?key=pk_your_public_key&lang=bg")
}
inner class Bridge {
@JavascriptInterface
fun postMessage(json: String) {
val message = JSONObject(json)
when (message.getString("type")) {
"jobyhood:needs-token" -> lifecycleScope.launch {
val token = yourBackend.jobyhoodToken() // see "Sign-in token"
web.evaluateJavascript("JobyhoodJobs.setToken(${JSONObject.quote(token)})", null)
}
"jobyhood:event" -> if (message.getString("kind") == "matches_available") {
runOnUiThread { showBadge(message.getInt("count")) }
}
}
}
}
// The pop-up opens in a web view of its own, so it can hand the session back to the embed.
inner class Popups : WebChromeClient() {
override fun onCreateWindow(view: WebView, dialog: Boolean, gesture: Boolean, msg: Message): Boolean {
val popup = WebView(this@JobsActivity).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
webViewClient = WebViewClient()
webChromeClient = this@Popups
}
popup.tag = Dialog(this@JobsActivity).apply { setContentView(popup); show() }
(msg.obj as WebView.WebViewTransport).webView = popup
msg.sendToTarget()
return true
}
override fun onCloseWindow(window: WebView) {
(window.tag as? Dialog)?.dismiss()
window.destroy()
}
}
}import UIKit
import WebKit
final class JobsViewController: UIViewController, WKScriptMessageHandler, WKUIDelegate {
private var webView: WKWebView!
override func viewDidLoad() {
super.viewDidLoad()
let config = WKWebViewConfiguration()
config.userContentController.add(self, name: "jobyhood") // events; e-mail-only sign-in in the pop-up
webView = WKWebView(frame: view.bounds, configuration: config)
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
webView.uiDelegate = self
view.addSubview(webView)
var url = URLComponents(string: "https://jobyhood.com/embed/jobs")!
url.queryItems = [URLQueryItem(name: "key", value: "pk_your_public_key"),
URLQueryItem(name: "lang", value: "bg")]
webView.load(URLRequest(url: url.url!))
}
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
guard let text = message.body as? String,
let json = try? JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any],
let type = json["type"] as? String else { return }
if type == "jobyhood:needs-token" {
Task {
let token = try await yourBackend.jobyhoodToken() // see "Sign-in token"
let literal = String(data: try JSONEncoder().encode(token), encoding: .utf8)!
_ = try? await webView.evaluateJavaScript("JobyhoodJobs.setToken(\(literal))")
}
} else if type == "jobyhood:event", json["kind"] as? String == "matches_available",
let count = json["count"] as? Int {
showBadge(count)
}
}
// The pop-up opens in a web view of its own, so it can hand the session back to the embed.
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
let popup = WKWebView(frame: view.bounds, configuration: configuration)
popup.uiDelegate = self
view.addSubview(popup)
return popup
}
func webViewDidClose(_ webView: WKWebView) { webView.removeFromSuperview() }
}- Keep the web view on jobyhood.com: the bridge is visible to whatever page it loads. Open links that leave the embed, such as the Terms, in the system browser.
- Keep JavaScript and DOM storage on: the embed keeps its session in its own storage.
- CV import opens a file picker. On Android implement
WebChromeClient.onShowFileChooser; WKWebView shows the picker itself. - Pass
lang, so the embed follows your app rather than the phone's language. - Showing a page of your own that carries the loader works as on the web: its
onEventgets the events, and passing them to the app is up to that page.
Headless API
Send your public key on every request as X-Jobyhood-Partner-Key: pk_…. The host is https://api.jobyhood.com. Responses are JSON with camelCase keys, and browser calls are allowed from your registered origins.
| Endpoint | Purpose |
|---|---|
GET /v1/partners/config/ | your name, theme, defaults, allowed origins, consentVersion, attribution strings and Jobyhood URLs |
GET /v1/jobs/ | postings, with search, role, city, country, page and page_size; your defaults apply and the caller can override them; each row carries partnerApplyUrl |
GET /v1/jobs/{id}/ | one posting |
POST /v1/partners/sso/ | signs a user in (Sign-in token) |
POST /v1/partners/link/ | attaches your identity to a signed-in Jobyhood user (Linking) |
POST /v1/users/jobs/express-interest/ | {job_posting_id}, with the user's access token |
POST /v1/auth/jwt/refresh/ | {refresh}: a new access token for the user |
POST /v1/partners/events/ | {kind: "impression" | "job_view", job_posting_id} when you show postings yourself; the embed counts its own |
GET /v1/partners/stats/ | daily counts, with your server secret (Stats) |
curl -H "X-Jobyhood-Partner-Key: pk_your_public_key" \
"https://api.jobyhood.com/v1/jobs/?city=Sofia&page_size=20"curl -X POST https://api.jobyhood.com/v1/partners/events/ \
-H "X-Jobyhood-Partner-Key: pk_your_public_key" -H "Content-Type: application/json" \
-d '{"kind": "job_view", "job_posting_id": 42}'AI onboarding chat
The AI features live on https://matcher.jobyhood.com, with the same key header and the user's access token.
| Endpoint | Purpose |
|---|---|
POST /v1/matcher/onboarding-chat/ | {message, conversation_history, extracted_data, language}: the chat that fills a new user's profile |
POST /v1/matcher/cv-chat/ | the CV import conversation |
curl -X POST https://matcher.jobyhood.com/v1/matcher/onboarding-chat/ \
-H "X-Jobyhood-Partner-Key: pk_your_public_key" \
-H "Authorization: Bearer <the user’s access token>" -H "Content-Type: application/json" \
-d '{"message": "Hi", "conversation_history": [], "extracted_data": {}, "language": "bg"}'Your key never replaces the user's token: without one the answer is 401. An unknown or paused key gets 403 Unknown partner key., and a browser origin you have not registered gets 403 Origin not allowed for this partner. A partner without registered origins may call from anywhere. Nothing is billed to you per call.
Stats
GET https://api.jobyhood.com/v1/partners/stats/?from=2026-10-01&to=2026-10-21
Authorization: Partner <server secret>
200 {"range": {…}, "totals": {"impressions": 1200, "jobViews": 340, "signups": 25, "logins": 60, "interests": 18},
"daily": [{"date": "2026-10-01", "impressions": 40, "jobViews": 11, "signups": 1, "logins": 2, "interests": 0}, …]}At most 93 days per call; the default is the last 30 days. Counts only, never personal data. The partner dashboard shows the same numbers.
Limits and errors
| What | Limit |
|---|---|
| Requests with your key | 600 per minute per key |
| Sign-in | 30 per minute per client IP |
| Events | 120 per minute per key; impressions are deduplicated per IP per minute |
| Stats | 60 per minute per client IP |
Over a limit the API answers 429: wait and retry. A paused or unknown key is refused before any endpoint runs, with 403 {"detail": "Unknown partner key."}; a paused key looks the same as an unknown one on purpose. Sign-in refusals carry a code (the table).
Attribution
Wherever you show Jobyhood postings, show the strings from config/.attribution: the "Powered by Jobyhood" line and, when a shown posting comes from an employer's public careers feed (feedProvider is set), the feed notice. The embed shows both itself.
What data is shared
- Jobyhood is the data controller for profiles created through you.
- You receive aggregate counts, and the knowledge that your own user ids have a Jobyhood identity.
- You never receive profile content, CVs, messages, or which jobs a person looked at or applied to.
- About matches you receive one number, the
countofmatches_available. The matches themselves are shown only inside Jobyhood's frame.
Support
Questions, a new website or a test key: write to us through the form on the Partners page.