Partner API β Integration Guide
Everything you need to pull vacancies/applications and receive webhook events for a company on Arqed Jobs. For the full REST request/response schemas, see the API reference β this page is the narrative walkthrough, especially for webhooks.
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...
Missing, malformed, invalid, and revoked keys all return the exact
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."
curl https://api.jobs.example.com/v1/partner/vacancies \
-H "Authorization: Bearer pk_live_6f2c9a1e..."
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.
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.
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.
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.
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"
}
Identical whether the key is missing, malformed, unknown, or was revoked β on purpose, so a response never confirms a key used to be 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
PATCH /v1/partner/vacancies/: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.
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.