Fintech.careers

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.

  1. Generate an API key. An OWNER creates a scoped key from Company Settings in the app. See Getting an API key.
  2. Make your first authenticated REST call. Send the raw key as a bearer token, e.g. GET /v1/partner/vacancies. See Authenticating REST calls.
  3. Subscribe a webhook URL to an event. Register an endpoint and pick which events from the event catalog it should receive.
  4. 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.
  5. 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…

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.
  • PROPOSED and 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}") )
Use the exact raw request body bytes — the ones actually received on the wire, before any JSON parsing/re-serialization. Re-serializing and re-stringifying the parsed JSON before hashing will very often produce a byte-for-byte different string (key order, whitespace) and the signature won't match even though the payload is "the same" logically.
Never compare signatures with === / 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);

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.

Delivery order is not guaranteed. A retried delivery can arrive interleaved with newer events for the same entity, and nothing here promises deliveries land in the order their source events occurred. Sort by the envelope's own 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.

The settings UI's "test" button sends a synthetic delivery through this exact same path, so you can verify your endpoint end to end — but test deliveries never count toward the 20-failure auto-disable counter, so testing a currently-broken endpoint can't accidentally disable it for real traffic.

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.