πŸ”Œ Arqed Jobs Partner API

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…

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}") )
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.

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"
}

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.