Partner API — Integration Guide
Everything you need to pull vacancies/applications and receive webhook events for a company on Fintech Careers. For the full REST request/response schemas, see the API reference — this page is the narrative walkthrough, especially for webhooks.
What this API is for
The Fintech Careers Partner API lets a company pull and manage its own vacancies and applications over REST, and subscribe to webhooks to receive real-time events about its own company's activity on Fintech Careers — a vacancy getting published, a new application coming in, an application's status changing, and more. It is scoped strictly to one company's own data: there is no cross-company access of any kind.
Quickstart
The whole integration in five steps. Each one links down to the fuller section below it for the details.
- Generate an API key. An OWNER creates a scoped key from Company Settings in the app. See Getting an API key.
-
Make your first authenticated REST call. Send
the raw key as a bearer token, e.g.
GET /v1/partner/vacancies. See Authenticating REST calls. - Subscribe a webhook URL to an event. Register an endpoint and pick which events from the event catalog it should receive.
- Verify an incoming signature. Every delivery is HMAC-signed — verify it before trusting the payload. See Signature verification for working Node.js and Python code.
- You're done. Failed deliveries retry automatically for up to 24 hours; see Retries & auto-disable for what happens if your endpoint stays down.
Getting an API key
An OWNER of the company generates an API key from
Company Settings in the app (this guide doesn't
cover logging into the app itself). The raw key —
pk_live_<64 hex chars> — is shown
exactly once, at creation. It is never stored in
recoverable form on our side (we keep a SHA-256 hash), so if it's
lost, revoke it and create a new one.
Each key carries one or more scopes — READ,
WRITE — and a per-minute rate limit set by the OWNER.
A key scoped to READ only can never call a
WRITE endpoint, regardless of what it's asked to do.
Authenticating REST calls
Every partner REST request carries the raw key as a bearer token:
Authorization: Bearer pk_live_6f2c9a1e...
An unknown or revoked key always returns the same 401
— the response deliberately never reveals which of those it was,
so an attacker probing keys can't distinguish "wrong" from "used
to be right." (A completely missing key is distinguishable — see
Common error responses for the exact split.)
curl https://api.jobs.example.com/v1/partner/vacancies \
-H "Authorization: Bearer pk_live_6f2c9a1e..."
api.jobs.example.com above is a placeholder — swap
it for your actual API base URL before running anything.
Every list endpoint (GET /v1/partner/vacancies,
GET /v1/partner/applications) returns the same
paginated envelope — items is the allow-listed
shape described under Webhook payload
shapes (the REST and webhook shapes are identical, see
below), the rest is standard offset pagination:
{
"items": [ /* ...PartnerVacancyPayload or PartnerApplicationPayload objects... */ ],
"page": 1,
"pageSize": 20,
"totalItems": 42,
"totalPages": 3
}
page is 1-based, both page and
pageSize come from the identically-named query
params (defaults 1 / 20), and
pageSize is clamped server-side to
1–100 — asking for more than 100 per
page just gets you 100, not an error.
Each of those also has a single-item form —
GET /v1/partner/vacancies/:id and
GET /v1/partner/applications/:id — returning one
items-shaped object directly (no envelope). Both
404 identically whether the id doesn't exist or belongs to
another company; see
404 — cross-company isolation. There's also
GET /v1/partner/company, which takes no
:id at all — an API key belongs to exactly one
company, so it always returns that key's own profile, in the
same allow-listed shape as the company.* webhook
payload below.
Rate limits
Each key is limited to its own configured
rateLimitPerMinute, enforced as a fixed 60-second
window per key (not a sliding window / token bucket). Exceeding it
returns 429:
{
"statusCode": 429,
"message": "Rate limit exceeded",
"code": "RATE_LIMIT_EXCEEDED",
"metadata": {
"apiKeyId": "ak_3f9c...",
"retryAfterSeconds": 42
},
"timestamp": "2026-09-17T10:30:00.000Z",
"path": "/v1/partner/vacancies"
}
Wait at least metadata.retryAfterSeconds before
retrying. The limiter fails closed: if our rate
limiter backend is unreachable, requests are refused rather than
let through unlimited — you may see a burst of 429s
during an incident on our side even if you're well under your
normal limit.
Event catalog
The full, current event catalog — generated from the same source
of truth the webhook dispatcher itself reads
(PARTNER_EVENT_CATALOG /
KAFKA_EVENT_CLASS_TO_CATALOG_KEY), via
event-catalog.json next to this page (produced by
npm run export:partner-openapi). A subscription's
eventNames must be a subset of the event
column below; an unknown name is rejected at subscription-create
time, not silently ignored.
Loading the current catalog…
| Event | Entity | Source domain event | Fires when… |
|---|
Webhook payload shapes
Every webhook delivery has the same envelope, whatever the event:
{
"event": "vacancy.published",
"id": "<the entity's own id>",
"timestamp": "<ISO-8601, when the source event occurred>",
"data": { /* one of the four allow-listed shapes below */ }
}
data is an explicit, hand-picked allow-list per
entity kind — never a raw internal DTO passed through. The exact
same shape is what GET /v1/partner/vacancies and
GET /v1/partner/applications return, so there is no
back door to read a field the push side keeps out.
The example JSON below shows one populated row of real-looking
data, not every field's actual optionality — several fields
(description, website,
logoUrl, and similar) can genuinely be
null even though the example happens to show a
value. Treat any field typed string | null (or
similarly) in the API reference's
schema as something your integration must handle being absent,
regardless of what the example here shows.
company company.*
{
"id": "6f1a2b3c-45d6-4e7a-8b9c-1234567890ab",
"name": "Acme Robotics",
"description": "We build warehouse robots.",
"website": "https://acme-robotics.example.com",
"logoUrl": "https://cdn.jobs.example.com/logos/acme.png",
"industryId": "ind_software",
"size": "51-200",
"hqCity": "Berlin",
"hqCountryId": "country_de",
"status": "ACTIVE",
"createdAt": "2026-01-14T09:12:00.000Z",
"updatedAt": "2026-06-02T11:45:00.000Z"
}
status is ACTIVE or BLOCKED
— included specifically because company.blocked /
company.unblocked exist to tell you exactly this.
Pull the same shape any time with
GET /v1/partner/company — useful if you were only
subscribed to webhooks starting after the company's last change,
or just want a plain pull-based way to refresh it.
vacancy vacancy.*
{
"id": "8a2e5f10-9c3b-4d1e-a7f2-0987654321cd",
"companyId": "6f1a2b3c-45d6-4e7a-8b9c-1234567890ab",
"title": "Senior Backend Engineer",
"description": "Own our vacancies service end to end.",
"salary": { "from": 4000, "to": 6000, "currency": "EUR" },
"isSalaryPublic": true,
"experienceMonths": 36,
"level": "SENIOR",
"workFormats": ["REMOTE", "HYBRID"],
"employmentType": "FULL_TIME",
"skills": ["Node.js", "PostgreSQL"],
"techStack": ["TypeScript", "NestJS", "Kafka"],
"responsibilities": ["Design APIs", "Mentor engineers"],
"department": "Engineering",
"city": "Berlin",
"countryId": "country_de",
"categoryTagId": "cat_backend",
"questions": [
{
"id": "q1",
"text": "Years of production Node.js experience?",
"type": "TEXT",
"options": [],
"required": true
}
],
"status": "PUBLISHED",
"viewCount": 214,
"scheduledPublishAt": null,
"createdAt": "2026-05-30T08:00:00.000Z",
"updatedAt": "2026-06-02T11:45:03.000Z"
}
status is one of DRAFT,
PUBLISHED, SCHEDULED,
CLOSED — the vacancy's public lifecycle only.
Internal moderation state is a separate axis this payload never
exposes.
application application.*
{
"id": "c3d4e5f6-1122-4a5b-9c8d-abcdefabcdef",
"vacancyId": "8a2e5f10-9c3b-4d1e-a7f2-0987654321cd",
"companyId": "6f1a2b3c-45d6-4e7a-8b9c-1234567890ab",
"applicantUserId": "usr_7788",
"resumeId": "res_3344",
"answers": [{ "questionId": "q1", "answer": ["4 years"] }],
"coverLetter": "I'd love to help build the vacancies service.",
"status": "NEW",
"source": "SELF_APPLIED",
"proposalMessage": null,
"proposedAt": null,
"respondedAt": null,
"expiresAt": null,
"createdAt": "2026-06-02T12:00:00.000Z",
"updatedAt": "2026-06-02T12:00:00.000Z"
}
status is the ATS pipeline stage (NEW,
REVIEWED, PHONE_SCREEN,
ON_SITE, OFFER, ARCHIVED,
or WITHDRAWN) for a SELF_APPLIED row,
or a proposal state (PROPOSED,
PROPOSAL_DECLINED, PROPOSAL_REVOKED,
PROPOSAL_EXPIRED) for a SOURCED one that
hasn't turned into role_proposal.accepted yet.
roleProposal role_proposal.*
{
"id": "c3d4e5f6-1122-4a5b-9c8d-abcdefabcdef",
"vacancyId": "8a2e5f10-9c3b-4d1e-a7f2-0987654321cd",
"companyId": "6f1a2b3c-45d6-4e7a-8b9c-1234567890ab",
"candidateUserId": "usr_9911",
"status": "PROPOSED",
"proposalMessage": "We think you'd be a great fit for this role.",
"proposedAt": "2026-06-01T10:00:00.000Z",
"respondedAt": null,
"expiresAt": "2026-06-08T10:00:00.000Z",
"createdAt": "2026-06-01T10:00:00.000Z",
"updatedAt": "2026-06-01T10:00:00.000Z"
}
Once a proposal is accepted, the same underlying row is delivered
through application.* events instead (with
source: "SOURCED") — resume/answers/cover-letter
fields don't apply to a not-yet-accepted proposal, so they're not
on this shape at all.
Resolving ids to readable data
Several fields above are opaque foreign-key ids rather than
human-readable values — industryId,
hqCountryId, countryId,
categoryTagId, applicantUserId,
candidateUserId. Each has a lookup endpoint. All of
the reference-data ones below are unauthenticated (no API key
needed) and safe to cache aggressively — this data changes
rarely.
| Field | Resolve with |
|---|---|
countryId / hqCountryId |
GET /v1/countries (list, optional
?search=) or
GET /v1/countries/:id
|
industryId |
GET /v1/industries or
GET /v1/industries/:id
|
categoryTagId |
GET /v1/category-tags or
GET /v1/category-tags/:id
|
applicantUserId / candidateUserId
|
GET /v1/identity/users/:userId → public
profile (id, firstName,
lastName, photo) only —
deliberately no email/phone/resume access; see below.
|
GET /v1/reference-data bundles countries, industries
and category tags into a single response, if pulling all three
up front and caching them locally is more convenient than three
separate calls:
{
"countries": [ { "id": "country_de", "name": "Germany" }, ... ],
"industries": [ { "id": "ind_software", "name": "Software" }, ... ],
"categoryTags": [ { "id": "cat_backend", "name": "Backend" }, ... ]
}
Resumes are deliberately not exposed to partners.
resumeId on the application payload has
no partner-accessible lookup — even a company's own
session-authenticated recruiter can only see a candidate's resume
once it's PUBLISHED by that candidate; there's no
broader grant for a company to read a non-published resume tied
to its own application, and the partner API doesn't invent one.
Treat resumeId as an opaque reference you can echo
back into your own system, not something you resolve.
Controlling the hiring flow
Beyond reading vacancies and applications, the API can drive the hiring flow itself: change an application's ATS status (which is also how you reject a candidate), cancel a role proposal your company sent, and message a candidate directly. All three are WRITE-scope, thin gateways onto the same real commands the session-authenticated app itself uses — no business rule is reimplemented in the partner layer, so behavior here always matches what a recruiter sees in the normal UI.
Change status PATCH /v1/partner/applications/:id/status
Body: { "status": "REVIEWED" }. Moves the
application to any ATS pipeline stage: NEW,
REVIEWED, PHONE_SCREEN,
ON_SITE, OFFER, ARCHIVED.
Returns the application, re-fetched after the change, in the
same shape as GET .../applications/:id.
There is no separate "reject" action or "REJECTED"
status — to reject a candidate, set
status to ARCHIVED.
Two statuses can never be set through this route, and the real
aggregate refuses both with a 400
INVALID_APPLICATION_STATUS_TRANSITION:
-
WITHDRAWN— candidate-only. Only the candidate can withdraw their own application; this route can't do it for them, and can't move an application back out of it either. -
PROPOSEDand the rest of the role-proposal family (PROPOSAL_DECLINED,PROPOSAL_REVOKED,PROPOSAL_EXPIRED) — managed exclusively by the role-proposal endpoints below, in either direction: neither settable as a new value, nor movable out of if the application is currently in one of them.
Permission model: gated on company membership
(APPLICATION_MANAGE), not on who authored the
vacancy — any active OWNER/RECRUITER can move any application
through the pipeline. If the API key's original
creator is no longer an active company member (left, or
was removed) since the key was created, this 403s with
code: INSUFFICIENT_VACANCY_PERMISSION — a key
outlives its creator's membership, but the action it performs
does not.
Cancel a role proposal POST /v1/partner/applications/:id/revoke-proposal
No body. Only valid while the application is a
pending role proposal — status:
"PROPOSED", source: "SOURCED", not yet
answered by the candidate. On success, the application moves to
PROPOSAL_REVOKED, a terminal state, and the response
is the re-fetched application. Calling it again, or on a plain
self-applied application, or on a proposal the candidate already
answered, returns 409 PROPOSAL_NOT_PENDING.
Permission model here is deliberately stricter
than status changes above: revoking is
author-only — the API key's original creator
must literally be the vacancy's author, not just any company
member with APPLICATION_MANAGE. An OWNER or
RECRUITER who can otherwise manage the vacancy fine still gets
403 NOT_APPLICATION_OWNER if they didn't author it
themselves. This mirrors the real aggregate's own rule for
role-proposal actions, not a restriction the partner API adds on
top.
Send & read messages POST /v1/partner/applications/:id/messages GET /v1/partner/applications/:id/messages
POST body: { "content": "..." }. Sends
a real chat message to the candidate — the exact same direct
chat a recruiter would see and reply in from the normal app UI,
not a side-channel. GET lists that same thread,
paginated newest-first, same page/pageSize
query params as every other list endpoint.
// GET .../messages response item shape
{
"id": "message-uuid",
"authorId": "user-uuid",
"isFromCompany": true,
"content": "Thanks for applying! Are you available this week?",
"sentAt": "2026-09-17T10:30:00.000Z",
"editedAt": null,
"deletedAt": null
}
Read this carefully before using it in production: every message is sent as the API key's original creator — the same actor-identity rule every write on this API already follows for vacancies and applications (see Authenticating REST calls). This is a real, permanent consequence, not an implementation detail: the candidate sees that person as the sender in their normal chat UI, indefinitely — even if that person later leaves the company, or the key outlives them. There is no abstract "message from the company" identity in this system.
An application with no messages yet returns an empty,
zero-total page from GET — not an error. That's the
expected, common case, not a sign anything is wrong.
There is currently no message.* webhook
for messages the candidate sends back to you — only the pull
GET above. Correlating an arbitrary incoming chat
message to "which partner company, which application" isn't a
simple mapping: chat messages carry only the two participants'
user ids, no vacancy/application link at all, and a candidate
can have more than one application at the same company, which
would need an explicit (and currently undefined) disambiguation
rule. If you need near-real-time incoming messages today, poll
GET .../messages.
Signature verification
Every webhook request carries four headers:
| Header | Value |
|---|---|
X-Webhook-Id |
The delivery's own id (idempotency key on your side). |
X-Webhook-Event |
The event name, e.g. vacancy.published —
matches the body's event field.
|
X-Webhook-Timestamp |
Unix seconds, as a plain decimal string. |
X-Webhook-Signature |
Hex-encoded HMAC-SHA256, described below. |
The signature is computed exactly as:
signature = hex( HMAC_SHA256(secret, "{X-Webhook-Timestamp}.{raw request body}") )
=== / a plain
string comparison. A naive comparison exits as soon as it
finds the first mismatched byte, which leaks — through response
timing — how many leading bytes of your guess were correct. Use a
constant-time comparison (crypto.timingSafeEqual in
Node, hmac.compare_digest in Python) every time,
exactly like this codebase's own delivery-side implementation
does.
const crypto = require('crypto');
const express = require('express');
const WEBHOOK_SECRET = process.env.JOBS_WEBHOOK_SECRET;
function verifyWebhookSignature(secret, timestampSeconds, rawBody, signature) {
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestampSeconds}.${rawBody}`)
.digest('hex');
let expectedBuf, signatureBuf;
try {
expectedBuf = Buffer.from(expected, 'hex');
signatureBuf = Buffer.from(signature, 'hex');
} catch {
return false;
}
// Different lengths would make timingSafeEqual throw — treat that as "no match".
if (expectedBuf.length !== signatureBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, signatureBuf);
}
const app = express();
// express.raw() keeps req.body as a Buffer of the EXACT bytes received —
// do not use express.json() here, it would already have re-parsed the body
// by the time you see it, and rawBody would be lost.
app.post(
'/webhooks/jobs',
express.raw({ type: 'application/json' }),
(req, res) => {
const timestamp = req.header('X-Webhook-Timestamp');
const signature = req.header('X-Webhook-Signature');
const rawBody = req.body.toString('utf8');
if (!verifyWebhookSignature(WEBHOOK_SECRET, timestamp, rawBody, signature)) {
return res.status(401).send('invalid signature');
}
// Optional but recommended: reject a timestamp too far in the past to
// limit a stolen signature's replay window.
const skewSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (skewSeconds > 5 * 60) {
return res.status(401).send('stale timestamp');
}
const event = JSON.parse(rawBody);
console.log('received', event.event, event.id);
// ... handle event.event / event.data ...
res.status(200).end();
},
);
app.listen(3000);
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
WEBHOOK_SECRET = os.environ["JOBS_WEBHOOK_SECRET"]
app = Flask(__name__)
def verify_webhook_signature(secret: str, timestamp: str, raw_body: bytes, signature: str) -> bool:
message = f"{timestamp}.".encode("utf-8") + raw_body
expected = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
# hmac.compare_digest is constant-time — never use `==` here.
return hmac.compare_digest(expected, signature)
@app.route("/webhooks/jobs", methods=["POST"])
def webhook():
timestamp = request.headers.get("X-Webhook-Timestamp", "")
signature = request.headers.get("X-Webhook-Signature", "")
raw_body = request.get_data() # exact raw bytes, before any JSON parsing
if not verify_webhook_signature(WEBHOOK_SECRET, timestamp, raw_body, signature):
abort(401, "invalid signature")
# Optional but recommended: reject a timestamp too far in the past.
if abs(time.time() - float(timestamp)) > 5 * 60:
abort(401, "stale timestamp")
event = request.get_json()
print("received", event["event"], event["id"])
# ... handle event["event"] / event["data"] ...
return "", 200
Retries & auto-disable
A delivery that doesn't get a 2xx response (or times
out after 10s) is retried up to 5 attempts,
spanning roughly 24 hours:
| After attempt | Wait before next attempt |
|---|---|
| 1 | 1 minute |
| 2 | 15 minutes |
| 3 | 2 hours |
| 4 | 8 hours |
| 5 (final) | — marked EXHAUSTED, no further retries |
Respond 2xx as soon as you've durably queued the
event for your own processing — don't do slow work in the
handler itself, or you risk a timeout being treated as a failure
even though you did receive the event.
timestamp field (see
Webhook payload shapes) if your
integration cares about the order events actually happened in —
don't rely on arrival order.
A webhook subscription is automatically disabled after 20 distinct events in a row each exhaust all 5 attempts (a single event retried 5 times only ever counts once here). Any one delivery that succeeds resets this counter to zero. When a subscription auto-disables, the company OWNER is notified; re-enable it from Company Settings once your endpoint is healthy again.
Common error responses
Every error raised by this API surface (both REST and the
webhook-subscription management endpoints) shares this shape,
except the plain 404 below, which is Nest's own
default exception format:
401 — invalid or revoked API key
{
"statusCode": 401,
"message": "Invalid API key",
"code": "API_KEY_INVALID",
"metadata": {},
"timestamp": "2026-09-17T10:30:00.000Z",
"path": "/v1/partner/vacancies"
}
The status code, message, and shape are identical whether a key
was actually tried and rejected — unknown or revoked
(code: "API_KEY_INVALID", shown above) — on purpose,
so a response never confirms a specific key string used to be
valid. A request with no
Authorization header at all (or one that isn't
Bearer <token>-shaped) gets the same
401 status but
code: "API_KEY_MISSING" instead — that distinction
is safe to expose, since it only tells you "you didn't send a
key," never anything about whether a specific key value you sent
was ever valid.
403 — key missing the required scope
{
"statusCode": 403,
"message": "This API key does not have the required scope",
"code": "API_KEY_MISSING_SCOPE",
"metadata": {
"apiKeyId": "ak_3f9c...",
"requiredScope": "WRITE"
},
"timestamp": "2026-09-17T10:30:00.000Z",
"path": "/v1/partner/vacancies"
}
404 — cross-company isolation
Every :id-taking partner route —
GET/PATCH /v1/partner/vacancies/:id,
GET /v1/partner/applications/:id — returns a plain
404 — never a 403 — both when the id
doesn't exist at all AND when it belongs to a different company.
A 403 would confirm the id exists for someone else;
this never does.
{
"statusCode": 404,
"message": "Vacancy not found",
"error": "Not Found"
}
429 — rate limit exceeded
See Rate limits above for the full shape.
FAQ & troubleshooting
My signature verification fails intermittently — why?
The most common cause is clock skew, not a broken signature. The
signature itself (Signature
verification) doesn't expire — it's a hash of the timestamp
and body, valid however old the delivery is. The optional
"reject a stale timestamp" check shown in the sample code
(skewSeconds > 5 * 60) is your own
replay-window guard, and it compares
X-Webhook-Timestamp against your server's clock. If
your server's clock has drifted, that check can reject a
genuinely valid, on-time delivery. Make sure the machine
verifying signatures is NTP-synced, and double-check you're
comparing against the exact raw request body bytes (see the
callout in Signature verification) —
a re-serialized body is the other common cause of a mismatch
that looks like a signature problem but isn't.
How do I test webhooks against my local machine?
Your webhook URL has to be reachable from the public internet,
so a plain localhost URL won't receive anything.
During development, point the subscription at a public tunnel
into your local machine (a generic reverse-tunnel tool such as
ngrok, or your own equivalent) instead. Once a subscription
exists, use the "test" button in Company Settings to send a
synthetic delivery through the exact same path real events use
— see the callout under
Retries & auto-disable — so you can
confirm your handler and signature verification work before any
real event has to reach it.
What does "auto-disabled" actually mean, and how do I recover?
Covered fully under Retries &
auto-disable: a subscription disables itself after 20
distinct events in a row each exhaust all 5 retry attempts. In
practice this means your endpoint was down or erroring for a
sustained period and stopped receiving new deliveries entirely
rather than continuing to fail silently. To recover: fix
whatever was wrong with your endpoint, then re-enable the
subscription from Company Settings — it does not re-enable
itself once your endpoint recovers, and no events are
backfilled for the time it was disabled. There is currently no
way to list or replay exactly which events you missed while
disabled, so reconcile by re-pulling current state via
GET /v1/partner/vacancies/applications
(see Quickstart) rather than trying to
reconstruct the gap from webhook history.
I rotated infrastructure and my webhook URL changed. What do I update?
Update the subscription's URL from Company Settings, the same place you created it. Use the "test" button afterward to confirm the new URL is reachable and your signature verification still passes before relying on it for real traffic — test deliveries never affect the auto-disable counter, so this is safe to do even on a subscription that was previously disabled.
Generated content on this page (event catalog table) is produced by
npm run export:partner-openapi straight from
src/contexts/partner-integrations — see
docs/plans/partner-api-webhooks.md in the backend repo
for the full design history.