Partner developer docs

Integrate Jobyhood jobs

Everything an engineer needs to put Jobyhood jobs on your site or in your app: the hosted embed, sign-in, mobile web views and the headless API.

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.
CredentialWhere it livesWhat it does
Public key pk_…your pages and apps; it is visible in page sourceidentifies you: configuration, attribution, scope and rate limits
Server secret (64 hex characters)your backend onlysigns 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.

HTML
Loader with a sign-in hand-off and a matches badge
<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>
AttributeValuesNotes
data-keyyour public keyrequired
data-langbg, endefault: the visitor's browser language
data-themelight, dark, autoauto follows the visitor's system setting
data-accent#rrggbbbuttons and highlights
data-radius0–32corner radius in pixels
data-countrya two-letter code, such as BGthe country of the postings
data-filtersq=…&role=…&city=…&country=…the first filters; the visitor can still search
data-joba posting idopens that posting first
data-tokena sign-in tokensigns the visitor in on load (Sign-in token)
data-on-eventthe name of a global functioncalled like onEvent

JavaScript API

After loadWhat 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

kindWhen
signupan account was created through your sign-in
loginan existing Jobyhood user signed in through you
interestthe visitor expressed interest in a posting
needs-tokenthe frame asked for a sign-in token (onNeedsToken is called too)
matches_availablethe 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.

JavaScript
Listening to the frame and answering it
// <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.

ClaimRule
issyour partner slug
aud"jobyhood"
subyour stable user id, up to 255 characters
iat, expat most 600 seconds apart, with 60 seconds of clock leeway
jtiunique per token; each token works once
emailrequired
email_verifiedtrue; needed for account creation, and Jobyhood must trust your verification
given_name, family_name, phone, localeoptional
JavaScript
Node (jsonwebtoken)
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 });
});
Python
Python (PyJWT)
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.

HTTP
Signing a user in
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

CodeHTTPWhat to do
invalid_token, expired400mint a new token
replayed409mint a new token; each jti works once
consent_required400show the consent screen with the current consentVersion and retry
email_not_verified403the email is not verified, or Jobyhood does not trust your verification yet: offer sign-up on jobyhood.com
email_in_use409the email already has a Jobyhood account: link it; the token is not spent
email_mismatch400link/ only: the signed-in user's email differs from the token's
account_linked_elsewhere409this sub is connected to another Jobyhood profile
company_account, account_unavailable403link/ 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_disabled403contact Jobyhood
idp_unavailable502OpenID 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.

  • iss is your issuer and aud your client id.
  • iat and exp: valid now and issued within the last 600 seconds, with 60 seconds of leeway. Request a fresh token for the hand-off.
  • nonce or jti is required, and the token works once.
  • email is required, and email_verified must be the boolean true.
  • given_name, family_name, phone_number and locale are optional.
  • The discovery document must name the same issuer. It and its jwks_uri must 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.

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.

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.

HTTP
Linking, headless
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.

URL
The embed URL; the token goes through setToken
https://jobyhood.com/embed/jobs?key=pk_your_public_key&lang=bg

The 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:

MessageWhen
jobyhood:needs-tokenthe 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.

Kotlin
Android (WebView)
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()
        }
    }
}
Swift
iOS (WKWebView)
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 onEvent gets 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.

EndpointPurpose
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)
Shell
Listing jobs
curl -H "X-Jobyhood-Partner-Key: pk_your_public_key" \
  "https://api.jobyhood.com/v1/jobs/?city=Sofia&page_size=20"
Shell
Reporting a job view
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.

EndpointPurpose
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
Shell
One turn of the onboarding chat
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

HTTP
Daily counts
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

WhatLimit
Requests with your key600 per minute per key
Sign-in30 per minute per client IP
Events120 per minute per key; impressions are deduplicated per IP per minute
Stats60 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 count of matches_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.