ZatrudnijMnie API Documentation

Version: 1.0
Base URL: https://zatrudnijmnie.pl/api/v1
Authentication: API Key (Bearer token)


Spis treści

  1. Wprowadzenie
  2. Autoryzacja
  3. Uprawnienia
  4. Rate Limiting
  5. CORS
  6. Format odpowiedzi
  7. Walidacja parametrów
  8. Endpoints
  9. Webhooki – payload i sygnatura
  10. Słowniki wartości
  11. Limit pobrań danych kontaktowych
  12. Kody błędów
  13. Przykłady

Wprowadzenie

API ZatrudnijMnie umożliwia programowy dostęp do ogłoszeń kandydatów szukających pracy. Dostępne jest dla wszystkich Kont Zespołowych.

Dostępność API według planów

Plan API Access Limit aktywnych kluczy Limit pobrań kontaktów / 30 dni Webhooki
Team Basic 2 250
Team Premium 5 1 000
Team Premium Plus 10 2 000
Team Enterprise 50 ustalany indywidualnie

Rate limit jest konfigurowany per klucz (domyślnie 1 000 req/h i 10 000 req/dzień; maksymalnie 10 000 req/h i 100 000 req/dzień). Webhooki (webhooks.manage) są dostępne od planu Team Premium Plus – ograniczenie jest wymuszane server-side niezależnie od uprawnień zapisanych w kluczu.

Limit pobrań danych kontaktowych to co innego niż rate limit. Rate limit ogranicza liczbę żądań, limit pobrań – liczbę kandydatów, których dane osobowe zostały ujawnione. Szczegóły: Limit pobrań danych kontaktowych.

Dostęp jest odbierany automatycznie, gdy dostęp organizacji wygaśnie lub zostanie zawieszony – wszystkie klucze przestają wtedy działać (API_KEY_INVALID).


Autoryzacja

API wymaga klucza API przekazywanego w nagłówku HTTP.

Metody autoryzacji

1. Bearer Token (zalecane):

Authorization: Bearer zm_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

2. X-Api-Key Header:

X-Api-Key: zm_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Nazwy nagłówków są niewrażliwe na wielkość liter.

⚠️ Przekazywanie klucza w parametrze query (?api_key=...) jest zabronione – takie żądania są odrzucane (API_KEY_MISSING) i logowane jako podejrzana aktywność. Klucz w URL wycieka do logów serwerów, proxy i historii przeglądarki.

Format klucza

Klucz ma postać zm_live_ + 48 znaków szesnastkowych (łącznie 56 znaków) i jest walidowany wyrażeniem ^zm_live_[a-f0-9]{48}$. Nie istnieje osobne środowisko testowe (zm_test_) – klucze zawsze działają na danych produkcyjnych.

Generowanie klucza API

  1. Zaloguj się do konta zespołu
  2. Przejdź do Ustawienia → Dostęp / API
  3. Kliknij "Utwórz nowy klucz"
  4. Zapisz klucz natychmiast – nie będzie można go ponownie wyświetlić!

W bazie przechowywany jest wyłącznie skrót SHA-256 klucza oraz jego 8-znakowy prefiks (do identyfikacji w panelu). Utracony klucz można jedynie zregenerować, co dezaktywuje poprzedni.

Bezpieczeństwo kluczy

  • ⚠️ Nigdy nie udostępniaj klucza API publicznie
  • ⚠️ Nie commituj kluczy do repozytorium
  • ⚠️ Używaj zmiennych środowiskowych
  • ✅ Regeneruj klucze regularnie
  • ✅ Używaj osobnych kluczy dla różnych środowisk
  • ✅ Nadawaj kluczom minimalny wymagany zestaw uprawnień (patrz niżej)

Uprawnienia

Każdy klucz API ma zestaw uprawnień ustawiany przy tworzeniu i edycji w panelu zespołu. Brak wymaganego uprawnienia skutkuje odpowiedzią 403 PERMISSION_DENIED.

Uprawnienie Wymagane przez Domyślnie
listings.read GET /listings, GET /listings/:id ✅ włączone
contacts.read GET /listings/:id/cv oraz pola contact i cvUrl w GET /listings/:id ✅ włączone
analytics.read GET /analytics, GET /analytics/usage ✅ włączone
webhooks.manage wszystkie endpointy /webhooks ❌ wyłączone (Team Premium Plus i wyżej)

Endpointy słownikowe (/positions, /cities, /contract-types) wymagają wyłącznie ważnego klucza – nie sprawdzają żadnego uprawnienia.

Least-privilege: dostęp do danych kontaktowych kandydata i CV wymaga jawnego uprawnienia contacts.read. Uprawnienie listings.read samo w sobie nie otwiera dostępu do danych osobowych – klucz bez zapisanej sekcji contacts otrzyma 403 PERMISSION_DENIED na GET /listings/:id/cv, a GET /listings/:id zwróci ogłoszenie bez pól contact i cvUrl. Jeśli klucz ma korzystać z danych kontaktowych, zapisz jego uprawnienia w panelu zespołu z zaznaczonym contacts.read.

webhooks.manage jest weryfikowane dwuetapowo: klucz musi mieć to uprawnienie i organizacja musi mieć plan team_premium_plus albo team_enterprise. Obniżenie planu natychmiast blokuje endpointy webhooków, nawet jeśli uprawnienie pozostało w kluczu.


Limit pobrań danych kontaktowych

Każdy plan zawiera limit unikalnych kandydatów, których dane kontaktowe (i CV) można pobrać przez API w oknie 30 dni kroczących.

Czym różni się od rate limitu: rate limit ogranicza liczbę żądań HTTP, ten limit – liczbę osób, których dane osobowe zostały ujawnione. Można wykonać tysiące żądań listy ogłoszeń bez naruszenia limitu pobrań.

Zasady liczenia

  • Okno 30 dni kroczących, niezależnie od długości wykupionego okresu dostępu. Dostęp roczny nie daje dwunastokrotności limitu.
  • Unikalni kandydaci – ten sam kandydat pobrany ponownie w oknie nie zużywa kolejnej jednostki. Ponowne pobranie jest możliwe także po wyczerpaniu limitu.
  • Zakres: organizacja, nie klucz. Wszystkie klucze zespołu dzielą jedną pulę.
  • Liczą się wyłącznie pobrania przez API. Praca w panelu webowym nie jest w żaden sposób ograniczana.

Które wywołania zużywają limit

Endpoint Zużywa jednostkę
GET /listings ❌ nie – lista nie zawiera danych kontaktowych
GET /listings/:id z uprawnieniem contacts.read ✅ tak, o ile kandydat nie był pobrany w bieżącym oknie
GET /listings/:id bez contacts.read ❌ nie – odpowiedź nie zawiera pól contact ani cvUrl
GET /listings/:id/cv ✅ tak, o ile kandydat nie był pobrany w bieżącym oknie
/positions, /cities, /contract-types, /analytics ❌ nie

Nagłówki

Zwracane przy każdej odpowiedzi endpointów ujawniających dane kontaktowe – również przy odmowie:

X-Contact-Quota-Limit: 1000
X-Contact-Quota-Remaining: 847
X-Contact-Quota-Reset: 2026-09-27T00:00:00.000Z

X-Contact-Quota-Reset to moment, w którym z okna wypadnie najstarsze pobranie – wtedy zwolni się pierwsza jednostka.

Po przekroczeniu limitu

{
  "success": false,
  "error": {
    "code": "CONTACT_QUOTA_EXCEEDED",
    "message": "Wyczerpano limit pobrań danych kontaktowych (1000 na 30 dni). Limit odnowi się 27.09.2026.",
    "used": 1000,
    "limit": 1000,
    "resetAt": "2026-09-27T00:00:00.000Z"
  },
  "timestamp": "2026-08-31T10:12:33.000Z"
}

Status 402 Payment Required – odróżnialny od 403 PERMISSION_DENIED (brak uprawnienia) i 429 RATE_LIMIT_EXCEEDED (zbyt wiele żądań).

Pakiety dokupowe

Właściciel zespołu może dokupić pakiet +250 pobrań w zakładce API panelu zespołu. Pakiet jest ważny 30 dni od zakupu, a niewykorzystane pobrania przepadają wraz z jego wygaśnięciem. Zakup jest jednorazowy – bez subskrypcji i automatycznych odnowień.


Rate Limiting

API stosuje limity requestów na godzinę i dzień, konfigurowane per klucz API.

Limity są egzekwowane trwale (persistent) w oparciu o logi requestów w bazie danych, co zapewnia spójność limitów w środowiskach wieloinstancyjnych.

Wartości domyślne i maksymalne:

Ustawienie Domyślnie Maksimum
rate_limit_per_hour 1 000 10 000
rate_limit_per_day 10 000 100 000

Nagłówki odpowiedzi

Zwracane przy każdej odpowiedzi po pomyślnej autoryzacji klucza – w tym przy 429 – i udostępnione przeglądarkom przez Access-Control-Expose-Headers. Odpowiedzi 401 (brak lub nieprawidłowy klucz) tych nagłówków nie zawierają:

X-RateLimit-Limit-Hour: 1000
X-RateLimit-Limit-Day: 10000
X-RateLimit-Remaining-Hour: 995
X-RateLimit-Remaining-Day: 9990

Przekroczenie limitu

{
  "success": false,
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Przekroczono limit requestów (godzinowy). Spróbuj ponownie za 3600 sekund.",
    "retryAfter": 3600
  },
  "timestamp": "2026-01-01T12:00:00.000Z"
}

Komunikat wskazuje, który limit został wyczerpany (godzinowy albo dzienny). Nagłówek Retry-After zawiera czas w sekundach do odnowienia limitu.


CORS

API v1 może być używane zarówno z backendów (server-to-server), jak i z aplikacji przeglądarkowych.

Lista dozwolonych originów jest jawną konfiguracją – nie jest używany wildcard *.

Kolejność ustalania listy:

  1. zmienna API_V1_CORS_ORIGINS (originy rozdzielone przecinkami) – konfiguracja dedykowana dla publicznego API,
  2. w razie jej braku – główna zmienna CORS_ORIGIN,
  3. w środowisku deweloperskim, gdy żadna nie jest ustawiona – http://localhost:3000 i http://127.0.0.1:3000,
  4. w produkcji/stagingu, gdy żadna nie jest ustawiona – żaden origin nie jest dopuszczany (fail-closed).

Zachowanie:

  • Access-Control-Allow-Origin jest ustawiane wyłącznie przy dokładnym dopasowaniu nagłówka Origin do listy; w przeciwnym razie nagłówek nie jest wysyłany w ogóle.
  • Dla dopasowanych originów zwracane jest również Access-Control-Allow-Credentials: true.
  • Odpowiedzi zawsze zawierają Vary: Origin.
  • Żądania preflight (OPTIONS) kończą się statusem 204 bez treści.
  • Dozwolone metody: GET, POST, PUT, DELETE, OPTIONS. Dozwolone nagłówki: Origin, X-Requested-With, Content-Type, Accept, Authorization, X-API-Key.

CORS ma znaczenie tylko wtedy, gdy wywołujesz API z przeglądarki (frontendu) z innej domeny. Dla integracji server-to-server (backend, cron, n8n, Make, Zapier itp.) CORS w praktyce nie ma znaczenia – żądania bez nagłówka Origin nie są przez niego ograniczane.

Jeśli integrujesz się z poziomu przeglądarki, skontaktuj się z nami w celu dodania Twojej domeny do listy dozwolonych originów.


Format odpowiedzi

Sukces

{
  "success": true,
  "data": { },
  "meta": {
    "timestamp": "2026-01-01T12:00:00.000Z"
  }
}

Sukces z paginacją

{
  "success": true,
  "data": [],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 150,
    "totalPages": 8,
    "hasMore": true
  },
  "meta": {
    "timestamp": "2026-01-01T12:00:00.000Z"
  }
}

Błąd

{
  "success": false,
  "error": {
    "code": "ERROR_CODE",
    "message": "Opis błędu"
  },
  "timestamp": "2026-01-01T12:00:00.000Z"
}

Pole meta.timestamp (sukces) i timestamp (błąd) są obecne w każdej odpowiedzi. Niektóre endpointy dokładają do meta dodatkowe pola (np. note przy tworzeniu webhooka).


Walidacja parametrów

API rozróżnia dwie klasy parametrów.

1. Parametry zakresowe – przycinane po cichu. Wartość spoza zakresu nie powoduje błędu; jest sprowadzana do najbliższej dozwolonej.

Parametr Zachowanie przy wartości spoza zakresu
page przycinane do zakresu 1–1000 (maksymalnie 1000 stron)
limit (listings) przycinane do zakresu 1–100
limit (cities) przycinane do maks. 500
limit (deliveries) przycinane do maks. 100
days (analytics) przycinane do zakresu 1–30

2. Filtry – walidowane, zwracają VALIDATION_ERROR (400). Nieprawidłowa wartość filtra nigdy nie jest po cichu pomijana, ponieważ pominięcie zwróciłoby szerszy zbiór danych niż zamówiony.

Parametr Reguła
position każda wartość musi być poprawnym UUID
city musi być poprawnym UUID
work_mode wyłącznie on-site, hybrid, remote
experience wyłącznie 0-2, 2-5, 5+
contract_types wyłącznie kody z GET /contract-types (niewrażliwe na wielkość liter)
sort wyłącznie newest, oldest, salary_high, salary_low
salary_min, salary_max nieujemne liczby; salary_min nie może przekraczać salary_max
filtry wielowartościowe maksymalnie 25 wartości na filtr

Nieprawidłowy UUID w ścieżce (np. /listings/:id) zwraca INVALID_ID (400).

Pusty parametr = brak parametru. ?city=, ?work_mode= czy ?sort= (a także wartości złożone z samych spacji) są traktowane jak nieprzekazanie filtra, a nie jak błąd. Dzięki temu można bezpiecznie budować query z pustych pól formularza.

Parametry wielowartościowe

Filtry position, work_mode, experience i contract_types przyjmują wiele wartości w obu konwencjach – powtórzony parametr i lista rozdzielona przecinkami. Obie formy są równoważne:

# Równoważne zapisy
?experience=2-5&experience=5%2B
?experience=2-5,5%2B

Duplikaty i nadmiarowe białe znaki są usuwane automatycznie.

⚠️ Pamiętaj o URL-enkodowaniu wartości zawierających znaki specjalne – 5+ musi zostać przesłane jako 5%2B, w przeciwnym razie + zostanie zdekodowany jako spacja i wartość nie przejdzie walidacji.


Endpoints

Listings

GET /listings

Pobiera listę aktywnych ogłoszeń kandydatów. Zwracane są wyłącznie ogłoszenia w statusie active.

Wymaga uprawnienia: listings.read

Parametry query:

Parametr Typ Wiele wartości Opis Przykład
page int Numer strony (default: 1, max: 1000) ?page=2
limit int Wyników na stronę (1-100, default: 20) ?limit=50
position UUID ID stanowiska ?position=uuid1,uuid2
city UUID ID miasta (tylko jedna wartość) ?city=uuid
work_mode string Tryb pracy: on-site, hybrid, remote ?work_mode=remote,hybrid
experience string Doświadczenie: 0-2, 2-5, 5+ ?experience=2-5,5%2B
salary_min int Dolna granica oczekiwanego wynagrodzenia ?salary_min=5000
salary_max int Górna granica oczekiwanego wynagrodzenia ?salary_max=15000
contract_types string Kody rodzajów umów (niewrażliwe na wielkość liter) ?contract_types=B2B,UOP
sort string Sortowanie: newest (default), oldest, salary_high, salary_low ?sort=salary_high
search string Wyszukiwanie tekstowe w tytule i opisie (max 100 znaków) ?search=python

Kolumna „Wiele wartości" oznacza filtry przyjmujące zarówno listę po przecinku, jak i powtórzony parametr (maks. 25 wartości na filtr).

Uwagi:

  • salary_min / salary_max filtrują pole salary_expectation (oczekiwania kandydata), nie widełki oferty.
  • search jest sanityzowane – dozwolone są litery (w tym polskie), cyfry, spacje i myślnik; pozostałe znaki są zamieniane na spacje.
  • contract_types przyjmuje kody ze słownika GET /contract-types w dowolnej wielkości liter (praktyka = PRAKTYKA = Praktyka). Kod spoza słownika zwraca VALIDATION_ERROR.
  • Sortowanie newest używa refreshed_at (odświeżenie ogłoszenia przez kandydata), a dopiero potem created_at.

Przykład request:

curl -X GET "https://zatrudnijmnie.pl/api/v1/listings?work_mode=remote&limit=10" \
  -H "Authorization: Bearer zm_live_YOUR_API_KEY"

Przykład response:

{
  "success": true,
  "data": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "title": "Senior Python Developer szuka pracy zdalnej",
      "jobTitle": "Senior Python Developer szuka pracy zdalnej",
      "position": {
        "id": "3f1a0c22-1111-4a2b-8c3d-0e1f2a3b4c5d",
        "name": "Python Developer",
        "slug": "python-developer"
      },
      "city": {
        "id": "7b2c9d10-2222-4e5f-9a1b-2c3d4e5f6a7b",
        "name": "Warszawa",
        "voivodeship": "Mazowieckie",
        "slug": "warszawa"
      },
      "workMode": "remote",
      "workTime": "full_time",
      "experienceYears": "5+",
      "salaryExpectation": 25000,
      "salaryType": "brutto",
      "contractTypes": ["B2B", "UOP"],
      "createdAt": "2026-01-01T10:00:00.000Z",
      "refreshedAt": "2026-01-01T10:00:00.000Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 10,
    "total": 150,
    "totalPages": 15,
    "hasMore": true
  },
  "meta": {
    "timestamp": "2026-01-01T12:00:00.000Z"
  }
}

title i jobTitle zawierają tę samą wartość – jobTitle istnieje wyłącznie dla kompatybilności wstecznej. position i city mogą być null, jeśli kandydat ich nie wskazał. Lista nigdy nie zawiera danych kontaktowych ani opisu ogłoszenia – te są dostępne wyłącznie w endpoincie szczegółów.


GET /listings/:id

Pobiera szczegóły ogłoszenia. Dane kontaktowe kandydata (contact) i cvUrl są dołączane tylko, gdy klucz API ma uprawnienie contacts.read.

Wymaga uprawnienia: listings.read (dla pól contact / cvUrl dodatkowo contacts.read)

Parametry path:

  • id – UUID ogłoszenia

Przykład request:

curl -X GET "https://zatrudnijmnie.pl/api/v1/listings/550e8400-e29b-41d4-a716-446655440000" \
  -H "Authorization: Bearer zm_live_YOUR_API_KEY"

Przykład response:

{
  "success": true,
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "title": "Senior Python Developer szuka pracy zdalnej",
    "jobTitle": "Senior Python Developer szuka pracy zdalnej",
    "description": "Doświadczony programista Python z 8-letnim stażem...",
    "position": {
      "id": "3f1a0c22-1111-4a2b-8c3d-0e1f2a3b4c5d",
      "name": "Python Developer",
      "slug": "python-developer"
    },
    "city": {
      "id": "7b2c9d10-2222-4e5f-9a1b-2c3d4e5f6a7b",
      "name": "Warszawa",
      "voivodeship": "Mazowieckie",
      "slug": "warszawa"
    },
    "workMode": "remote",
    "workTime": "full_time",
    "experienceYears": "5+",
    "salaryExpectation": 25000,
    "salaryType": "brutto",
    "contractTypes": ["B2B", "UOP"],
    "educationLevel": "master",
    "languages": [
      {"language": "Angielski", "level": "C1"},
      {"language": "Niemiecki", "level": "B2"}
    ],
    "contact": {
      "fullName": "Jan Kowalski",
      "email": "[email protected]",
      "phone": "123456789"
    },
    "cvUrl": "/api/v1/listings/550e8400-e29b-41d4-a716-446655440000/cv",
    "createdAt": "2026-01-01T10:00:00.000Z",
    "refreshedAt": "2026-01-01T10:00:00.000Z"
  },
  "meta": {
    "timestamp": "2026-01-01T12:00:00.000Z"
  }
}

Dane kontaktowe (contact) oraz CV (cvUrl) są zwracane wyłącznie, gdy klucz API ma uprawnienie contacts.read. Klucze bez tego uprawnienia otrzymują szczegóły ogłoszenia bez pól contact i cvUrl – dane osobowe nie są wtedy nawet pobierane z bazy (minimalizacja danych, RODO Art. 5(1)(c)).

Gdy klucz ma uprawnienie contacts.read, obiekt contact (imię i nazwisko, email, telefon) jest zawsze obecny – pola te są wymagane w każdym ogłoszeniu.

CV (cvUrl) jest dodatkowo opcjonalne – pole pojawia się tylko gdy kandydat dołączył CV do ogłoszenia. URL wskazuje na endpoint API do pobrania CV.

⚠️ Uwaga: Każde ujawnienie danych kontaktowych i CV jest rejestrowane w dedykowanym logu audytowym zgodnie z RODO (Art. 30) – zapisywany jest klucz API, organizacja, ogłoszenie i identyfikator kandydata.


GET /listings/:id/cv

Pobiera CV kandydata. Zwraca tymczasowy (presigned) URL do pobrania pliku.

Wymaga uprawnienia: contacts.read

Parametry path:

  • id – UUID ogłoszenia

CV jest opcjonalne – nie każde ogłoszenie je zawiera. Sprawdź obecność pola cvUrl w odpowiedzi GET /listings/:id.

Przykład request:

curl -X GET "https://zatrudnijmnie.pl/api/v1/listings/550e8400-e29b-41d4-a716-446655440000/cv" \
  -H "Authorization: Bearer zm_live_YOUR_API_KEY"

Przykład response (CV dostępne):

{
  "success": true,
  "data": {
    "downloadUrl": "https://storage.example.com/cv/signed-url?token=...",
    "filename": "Jan_Kowalski_CV.pdf"
  },
  "meta": {
    "timestamp": "2026-01-01T12:00:00.000Z"
  }
}

Przykład response (brak CV):

{
  "success": false,
  "error": {
    "code": "CV_NOT_AVAILABLE",
    "message": "To ogłoszenie nie zawiera CV"
  },
  "timestamp": "2026-01-01T12:00:00.000Z"
}

⚠️ Presigned URL jest ważny przez krótki czas (domyślnie 60 sekund, konfigurowalne w zakresie 10–600 s). Pobierz plik niezwłocznie po otrzymaniu URL – nie zapisuj tego linku w swojej bazie.


Analytics

GET /analytics

Pobiera zagregowane statystyki użycia API dla całej organizacji.

Wymaga uprawnienia: analytics.read

Parametry query:

Parametr Typ Opis Default
days int Okres w dniach (1-30) 30

Przykład response:

{
  "success": true,
  "data": {
    "period": {
      "days": 30,
      "from": "2025-12-02T12:00:00.000Z",
      "to": "2026-01-01T12:00:00.000Z"
    },
    "requests": {
      "total": 15420,
      "successful": 15380,
      "failed": 40,
      "successRate": "99.74%"
    },
    "performance": {
      "avgResponseTimeMs": 127.5
    },
    "breakdown": {
      "byEndpoint": {
        "/api/v1/listings": 12000,
        "/api/v1/listings/:id": 3420
      },
      "byDay": [
        {"date": "2025-12-31", "count": 520},
        {"date": "2026-01-01", "count": 480}
      ]
    }
  },
  "meta": {
    "timestamp": "2026-01-01T12:00:00.000Z"
  }
}

successRate i errorRate są zwracane jako stringi z symbolem procenta (np. "99.74%"), a nie jako liczby.


GET /analytics/usage

Pobiera statystyki użycia w rozbiciu na poszczególne klucze API organizacji. Zwracane są wszystkie klucze zespołu (aktywne i dezaktywowane), niezależnie od tego, którym kluczem wykonano żądanie.

Wymaga uprawnienia: analytics.read

Przykład response:

{
  "success": true,
  "data": {
    "team": {
      "id": "9c3f1b44-3333-4a5b-8c6d-7e8f9a0b1c2d",
      "name": "Firma Rekrutacyjna XYZ",
      "plan": "team_premium"
    },
    "keys": [
      {
        "keyId": "a1b2c3d4-4444-4e5f-9a0b-1c2d3e4f5a6b",
        "keyName": "Production Key",
        "keyPrefix": "abc12345",
        "isActive": true,
        "totalRequests": 15420,
        "totalErrors": 40,
        "errorRate": "0.26%",
        "lastUsedAt": "2026-01-01T12:00:00.000Z",
        "createdAt": "2025-06-01T00:00:00.000Z"
      }
    ],
    "totals": {
      "activeKeys": 3,
      "totalKeys": 5,
      "totalRequests": 45000,
      "totalErrors": 120
    }
  },
  "meta": {
    "timestamp": "2026-01-01T12:00:00.000Z"
  }
}

keyPrefix to 8 znaków po przedrostku zm_live_ – służy do rozpoznania klucza w panelu. plan przyjmuje wartości team_premium, team_premium_plus lub team_enterprise. Liczniki totalRequests / totalErrors są narastające od utworzenia klucza (nie podlegają parametrowi days).


Dictionaries

Endpointy słownikowe wymagają jedynie ważnego klucza API – nie sprawdzają uprawnień.

GET /positions

Pobiera słownik stanowisk. Struktura jest dwupoziomowa: rekordy z parent_id: null to kategorie, pozostałe to stanowiska w ramach kategorii.

{
  "success": true,
  "data": [
    {
      "id": "1a2b3c4d-5555-4a6b-8c7d-9e0f1a2b3c4d",
      "name": "IT / Programowanie",
      "slug": "it-programowanie",
      "parent_id": null,
      "display_order": 1
    },
    {
      "id": "3f1a0c22-1111-4a2b-8c3d-0e1f2a3b4c5d",
      "name": "Python Developer",
      "slug": "python-developer",
      "parent_id": "1a2b3c4d-5555-4a6b-8c7d-9e0f1a2b3c4d",
      "display_order": 12
    }
  ],
  "meta": {
    "timestamp": "2026-01-01T12:00:00.000Z"
  }
}

Odpowiedź używa snake_case (parent_id, display_order), ponieważ zwraca surowe rekordy słownika. Wyniki są cache'owane po stronie serwera – zmiany w słowniku mogą być widoczne z opóźnieniem.


GET /cities

Pobiera słownik miast (tylko aktywne), posortowany alfabetycznie.

Parametry query:

Parametr Typ Opis
query string Filtr po nazwie – dopasowanie od początku nazwy, min. 2 znaki (krótsze są ignorowane)
limit int Max wyników (default: 100, max: 500)
curl -X GET "https://zatrudnijmnie.pl/api/v1/cities?query=War" \
  -H "Authorization: Bearer zm_live_YOUR_API_KEY"
{
  "success": true,
  "data": [
    {
      "id": "7b2c9d10-2222-4e5f-9a1b-2c3d4e5f6a7b",
      "name": "Warszawa",
      "slug": "warszawa",
      "voivodeship": "Mazowieckie"
    }
  ],
  "meta": {
    "timestamp": "2026-01-01T12:00:00.000Z"
  }
}

GET /contract-types

Pobiera słownik rodzajów umów (tylko aktywne). Kody z pola code są wartościami przyjmowanymi przez filtr contract_types w GET /listings.

{
  "success": true,
  "data": [
    {"code": "UOP", "name": "Umowa o pracę"},
    {"code": "B2B", "name": "Kontrakt B2B"},
    {"code": "UZ", "name": "Umowa zlecenie"},
    {"code": "UOD", "name": "Umowa o dzieło"},
    {"code": "Praktyka", "name": "Praktyka / Staż"}
  ],
  "meta": {
    "timestamp": "2026-01-01T12:00:00.000Z"
  }
}

Zwróć uwagę, że kod Praktyka nie jest zapisany wielkimi literami. Filtr contract_types dopasowuje kody niezależnie od wielkości liter, ale w polu contractTypes odpowiedzi zwracana jest kanoniczna forma ze słownika. Nie zakładaj na sztywno listy pięciu kodów – pobieraj ją z tego endpointu.


Webhooks

Wszystkie endpointy webhooków wymagają uprawnienia webhooks.manage oraz planu Team Enterprise.

GET /webhooks

Pobiera listę skonfigurowanych webhooków organizacji, od najnowszego.

Wymaga uprawnienia: webhooks.manage

{
  "success": true,
  "data": [
    {
      "id": "b5c6d7e8-6666-4f0a-9b1c-2d3e4f5a6b7c",
      "name": "New Candidates Notification",
      "url": "https://your-system.com/webhook",
      "events": ["listing.created", "listing.updated"],
      "isActive": true,
      "totalSent": 1284,
      "totalFailed": 3,
      "lastTriggeredAt": "2026-01-01T11:59:00.000Z",
      "lastSuccessAt": "2026-01-01T11:59:00.000Z",
      "lastError": null,
      "createdAt": "2025-06-01T00:00:00.000Z",

      "webhook_name": "New Candidates Notification",
      "webhook_url": "https://your-system.com/webhook",
      "is_active": true,
      "total_sent": 1284,
      "total_failed": 3,
      "last_triggered_at": "2026-01-01T11:59:00.000Z",
      "last_success_at": "2026-01-01T11:59:00.000Z",
      "last_error": null,
      "created_at": "2025-06-01T00:00:00.000Z"
    }
  ],
  "meta": {
    "timestamp": "2026-01-01T12:00:00.000Z",
    "deprecation": "Pola snake_case (webhook_name, webhook_url, is_active, ...) są przestarzałe i zostaną usunięte w API v2. Używaj pól camelCase."
  }
}

⚠️ Pola snake_case są przestarzałe (deprecated). Endpoint zwracał je historycznie, w odróżnieniu od reszty API używającej camelCase. Obie formy niosą tę samą wartość i są zwracane równolegle, aby nie zepsuć istniejących integracji. Nowy kod powinien czytać wyłącznie pola camelCase – aliasy snake_case zostaną usunięte w API v2. Kształt obiektu jest teraz identyczny jak w odpowiedzi POST /webhooks.

Sekret webhooka nigdy nie jest zwracany przez ten endpoint.


POST /webhooks

Tworzy nowy webhook.

Wymaga uprawnienia: webhooks.manage

Body:

{
  "name": "New Candidates Notification",
  "url": "https://your-system.com/webhook",
  "events": ["listing.created", "listing.updated"]
}

Walidacja:

Pole Reguły
name wymagane, string, 1–100 znaków (po przycięciu białych znaków)
url wymagane, string, max 500 znaków (po przycięciu białych znaków), musi zaczynać się od https://
events opcjonalne; jeśli podane, musi być tablicą niepustą; pominięcie = subskrypcja wszystkich dostępnych eventów

Liczba webhooków na organizację jest ograniczona (domyślnie 10). Przekroczenie limitu zwraca 409 z kodem WEBHOOK_LIMIT_REACHED.

Adres URL przechodzi kontrolę SSRF: odrzucane są localhost, 0.0.0.0, domeny .local i .internal, adresy prywatne/loopback/link-local (IPv4 i IPv6, także w formie mapowanej), URL-e z osadzonymi danymi logowania oraz hosty, których nie da się rozwiązać w DNS. Kontrola jest powtarzana przy każdej wysyłce – webhook wskazujący na adres wewnętrzny zostanie pominięty.

Dostępne eventy:

  • listing.created – publikacja nowego ogłoszenia
  • listing.updated – aktualizacja ogłoszenia
  • listing.deleted – usunięcie ogłoszenia (patrz uwaga niżej)

Response zawiera secret (tylko raz!):

{
  "success": true,
  "data": {
    "id": "b5c6d7e8-6666-4f0a-9b1c-2d3e4f5a6b7c",
    "name": "New Candidates Notification",
    "url": "https://your-system.com/webhook",
    "events": ["listing.created", "listing.updated"],
    "isActive": true,
    "totalSent": 0,
    "totalFailed": 0,
    "lastTriggeredAt": null,
    "lastSuccessAt": null,
    "lastError": null,
    "createdAt": "2026-01-01T12:00:00.000Z",
    "secret": "abc123...xyz789"
  },
  "meta": {
    "timestamp": "2026-01-01T12:00:00.000Z",
    "note": "Zapisz secret - nie będzie można go ponownie wyświetlić"
  }
}

secret to 64 znaki szesnastkowe. Zapisz go bezpiecznie – jest niezbędny do weryfikacji sygnatury i nie da się go później odczytać (w bazie trzymany jest wyłącznie jego skrót).


DELETE /webhooks/:id

Usuwa webhook należący do Twojej organizacji.

Wymaga uprawnienia: webhooks.manage

{
  "success": true,
  "data": { "deleted": true },
  "meta": {
    "timestamp": "2026-01-01T12:00:00.000Z"
  }
}

Operacja jest idempotentna: odpowiedź {"deleted": true} jest zwracana także wtedy, gdy webhook o podanym ID nie istnieje lub należy do innej organizacji. Nie traktuj sukcesu jako potwierdzenia, że webhook istniał – zweryfikuj stan przez GET /webhooks.


POST /webhooks/:id/test

Wysyła testowy payload do webhooka.

Wymaga uprawnienia: webhooks.manage

Response:

{
  "success": true,
  "data": {
    "message": "Testowy webhook został wysłany",
    "webhookId": "b5c6d7e8-6666-4f0a-9b1c-2d3e4f5a6b7c"
  },
  "meta": {
    "timestamp": "2026-01-01T12:00:00.000Z"
  }
}

Testowy request wysyła event test.ping z payloadem:

{ "test": true, "message": "To jest testowy webhook z ZatrudnijMnie", "timestamp": "2026-01-01T12:00:00.000Z" }

Uwaga: test.ping nie jest eventem subskrybowanym – jest wysyłany do wskazanego webhooka niezależnie od jego listy events. Twój endpoint powinien go rozpoznawać i ignorować. Odpowiedź success: true oznacza, że wysyłka została zainicjowana, a nie że Twój serwer odpowiedział poprawnie – wynik sprawdź w GET /webhooks/:id/deliveries.


GET /webhooks/:id/deliveries

Zwraca historię wysyłek webhooka, od najnowszej.

Wymaga uprawnienia: webhooks.manage

Parametry query:

Parametr Typ Opis Default
limit int Max wyników (max: 100) 50

Przykład response:

{
  "success": true,
  "data": [
    {
      "id": "c6d7e8f9-7777-4a1b-9c2d-3e4f5a6b7c8d",
      "deliveryId": "d7e8f9a0-8888-4b2c-9d3e-4f5a6b7c8d9e",
      "eventType": "listing.created",
      "statusCode": 200,
      "success": true,
      "attemptNumber": 1,
      "responseTimeMs": 142,
      "errorMessage": null,
      "createdAt": "2026-01-01T10:00:00.000Z"
    }
  ],
  "meta": {
    "timestamp": "2026-01-01T12:00:00.000Z"
  }
}

statusCode: 0 oznacza, że połączenie nie doszło do skutku (timeout, błąd DNS, odrzucone TLS) – szczegóły znajdziesz w errorMessage. Każda próba (również ponowienie) tworzy osobny wpis z własnym attemptNumber.


Webhooki – format payloadu i weryfikacja sygnatury

Format payloadu

Każdy webhook POST zawiera JSON o stałej kopercie:

{
  "id": "d7e8f9a0-8888-4b2c-9d3e-4f5a6b7c8d9e",
  "event": "listing.created",
  "created_at": "2026-01-01T10:00:00.000Z",
  "data": { }
}

⚠️ Obiekt data nie jest tym samym formatem, co odpowiedź GET /listings/:id. Jest to płaski obiekt w snake_case z ograniczonym zestawem pól. Aby pobrać pełne dane ogłoszenia (w tym opis i dane kontaktowe), wykonaj GET /listings/:id z użyciem data.id.

data dla listing.created:

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "job_title": "Senior Python Developer szuka pracy zdalnej",
  "position_id": "3f1a0c22-1111-4a2b-8c3d-0e1f2a3b4c5d",
  "city_id": "7b2c9d10-2222-4e5f-9a1b-2c3d4e5f6a7b",
  "salary_expectation": 25000,
  "salary_type": "brutto",
  "experience_years": "5+",
  "work_mode": "remote",
  "work_time_preference": "full_time",
  "created_at": "2026-01-01T10:00:00.000Z"
}

data dla listing.updated – identyczny zestaw pól, z updated_at zamiast created_at.

data dla listing.deleted – identyczny zestaw pól, z deleted_at zamiast created_at.

Uwagi:

  • Pola opcjonalne (position_id, city_id, salary_expectation, work_mode, experience_years) mogą mieć wartość null.
  • job_title odpowiada polu title w REST API.
  • listing.deleted jest wysyłany zarówno przy usunięciu miękkim (z możliwością przywrócenia), jak i trwałym. Jeśli ogłoszenie zostało najpierw usunięte miękko, a później trwale, event wysyłany jest tylko raz – przy pierwszym usunięciu. Zdarzenie usunięcia nie niesie informacji, czy usunięcie jest odwracalne; jeśli ogłoszenie zostanie przywrócone, otrzymasz listing.updated.

Nagłówki żądania webhook

Content-Type: application/json
User-Agent: ZatrudnijMnie-Webhooks/1.0
X-Webhook-Id: d7e8f9a0-8888-4b2c-9d3e-4f5a6b7c8d9e
X-Webhook-Event: listing.created
X-Webhook-Timestamp: 1767261600000
X-Webhook-Signature: t=1767261600000,v1=abc123...

X-Webhook-Timestamp jest podawany w milisekundach (Unix epoch). Ta sama wartość występuje w polu t= sygnatury.

Przekierowania HTTP nie są śledzone (redirect: 'manual') – odpowiedź 3xx jest traktowana jak niepowodzenie. Twój endpoint musi odpowiadać bezpośrednio kodem 2xx.

Weryfikacja sygnatury

Sygnatura w nagłówku X-Webhook-Signature ma format t={timestamp},v1={hmac}, gdzie:

secretHash = SHA256_hex(secret)      // secret dostajesz tylko raz przy tworzeniu webhooka
signedPayload = "{timestamp}.{rawBody}"
hmac = HMAC-SHA256(key = secretHash, message = signedPayload)
signature = "t={timestamp},v1={hmac}"

⚠️ Kluczem HMAC jest szesnastkowy skrót SHA-256 sekretu jako string, a nie sam sekret. Pominięcie tego kroku to najczęstsza przyczyna nieudanej weryfikacji.

Weryfikuj sygnaturę na surowym ciele żądania (raw body), przed jakimkolwiek parsowaniem JSON – ponowna serializacja zmieni bajty i unieważni podpis.

Node.js:

const crypto = require('crypto');

function verifyWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
    const parts = Object.fromEntries(
        signatureHeader.split(',').map(p => {
            const idx = p.indexOf('=');
            return [p.slice(0, idx), p.slice(idx + 1)];
        })
    );

    const timestamp = parseInt(parts.t, 10);
    if (!Number.isFinite(timestamp) || !/^[0-9a-f]+$/i.test(parts.v1 || '')) {
        return false;
    }

    // Sprawdź tolerancję czasową (ochrona przed replay attacks).
    // timestamp jest w milisekundach.
    if (Math.abs(Date.now() - timestamp) > toleranceSec * 1000) {
        return false;
    }

    const secretHash = crypto.createHash('sha256').update(secret).digest('hex');
    const expected = crypto
        .createHmac('sha256', secretHash)
        .update(`${timestamp}.${rawBody}`)
        .digest('hex');

    if (parts.v1.length !== expected.length) {
        return false;
    }

    return crypto.timingSafeEqual(
        Buffer.from(parts.v1, 'hex'),
        Buffer.from(expected, 'hex')
    );
}

// Express: pozyskanie raw body
// app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
//     const ok = verifyWebhook(req.body.toString('utf8'), req.get('X-Webhook-Signature'), SECRET);
//     if (!ok) return res.sendStatus(401);
//     res.sendStatus(200);
// });

Python:

import hmac, hashlib, time

def verify_webhook(raw_body: str, signature_header: str, secret: str, tolerance_sec: int = 300) -> bool:
    parts = dict(p.split('=', 1) for p in signature_header.split(','))
    timestamp = int(parts['t'])

    # timestamp jest w milisekundach
    if abs(time.time() * 1000 - timestamp) > tolerance_sec * 1000:
        return False

    secret_hash = hashlib.sha256(secret.encode()).hexdigest()
    expected = hmac.new(
        secret_hash.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(parts['v1'], expected)

Polityka retry

Wysyłka jest uznana za nieudaną, gdy odpowiedź ma status inny niż 2xx (w tym 3xx), połączenie się nie powiedzie lub przekroczy 10 sekund.

Próba Opóźnienie od poprzedniej
1 (oryginalna)
2 1 minuta
3 5 minut
4 15 minut

Domyślnie wykonywane są 4 próby łącznie (max_retries = 3). Po wyczerpaniu prób wysyłka jest porzucana – każda próba trafia do historii dostarczeń, a licznik total_failed webhooka rośnie. Webhook nie jest automatycznie dezaktywowany.

Przed każdym ponowieniem sprawdzamy aktualny stan webhooka: jeśli w międzyczasie został wyłączony lub usunięty, zaplanowane ponowienia nie są wysyłane.

Wskazówki dla odbiorcy:

  • Odpowiadaj 2xx natychmiast po przyjęciu zdarzenia, a przetwarzanie wykonuj asynchronicznie – przekroczenie 10 s uruchamia ponowienie.
  • Zapewnij idempotencję na podstawie X-Webhook-Id / id z payloadu: ponowienie ma ten sam id dostarczenia, więc to samo zdarzenie może dotrzeć wielokrotnie.

Max rozmiar payloadu: 64 KB. Zdarzenia przekraczające ten limit nie są wysyłane.


Słowniki wartości

Wartości enumeracyjne występujące w odpowiedziach i filtrach:

Pole (REST) Pole (webhook) Dozwolone wartości
workMode work_mode on-site, hybrid, remote, null
workTime work_time_preference full_time, part_time, temporary
experienceYears experience_years 0-2, 2-5, 5+, null
salaryType salary_type brutto, netto
educationLevel primary, secondary, bachelor, master, phd, null
contractTypes kody z GET /contract-types
languages[].level A1, A2, B1, B2, C1, C2, Native

Plany organizacji (data.team.plan w /analytics/usage): team_premium, team_premium_plus, team_enterprise.


Kody błędów

Kod HTTP Opis
API_KEY_MISSING 401 Brak klucza API w nagłówku (lub próba przekazania go w query)
API_KEY_INVALID 401 Nieprawidłowy, nieaktywny lub wygasły klucz; wygasły/zawieszony dostęp organizacji; plan bez API
PERMISSION_DENIED 403 Klucz nie ma wymaganego uprawnienia (lub plan nie obejmuje webhooków)
NOT_FOUND 404 Zasób nie znaleziony (ogłoszenie nieistniejące lub nieaktywne)
WEBHOOK_NOT_FOUND 404 Webhook nie istnieje lub należy do innej organizacji
CV_NOT_AVAILABLE 404 Ogłoszenie nie zawiera CV lub CV jest niedostępne
INVALID_ID 400 Nieprawidłowy format UUID w ścieżce
VALIDATION_ERROR 400 Błąd walidacji treści żądania (POST /webhooks)
WEBHOOK_LIMIT_REACHED 409 Osiągnięto limit webhooków dla organizacji
CONTACT_QUOTA_EXCEEDED 402 Wyczerpano limit pobrań danych kontaktowych w oknie 30 dni
RATE_LIMIT_EXCEEDED 429 Przekroczono godzinowy lub dzienny limit requestów
CV_ERROR 500 Błąd generowania linku do CV
INTERNAL_ERROR 500 Wewnętrzny błąd serwera

Zalecana obsługa po stronie klienta: 401/403 – nie ponawiaj, popraw konfigurację klucza; 402 – nie ponawiaj, dokup pakiet pobrań lub poczekaj do daty z pola resetAt; 429 – ponów po czasie z nagłówka Retry-After; 5xx – ponów z wykładniczym backoffem.


Przykłady

cURL

# Lista ogłoszeń zdalnych
curl -X GET "https://zatrudnijmnie.pl/api/v1/listings?work_mode=remote&limit=10" \
  -H "Authorization: Bearer zm_live_YOUR_API_KEY"

# Kilka wartości jednego filtra - powtórzony parametr, nie przecinek
curl -G "https://zatrudnijmnie.pl/api/v1/listings" \
  --data-urlencode "experience=2-5" \
  --data-urlencode "experience=5+" \
  -H "Authorization: Bearer zm_live_YOUR_API_KEY"

# Szczegóły kandydata
curl -X GET "https://zatrudnijmnie.pl/api/v1/listings/550e8400-e29b-41d4-a716-446655440000" \
  -H "Authorization: Bearer zm_live_YOUR_API_KEY"

JavaScript (fetch)

const API_KEY = process.env.ZATRUDNIJMNIE_API_KEY;
const BASE_URL = 'https://zatrudnijmnie.pl/api/v1';

async function getListings(filters = {}) {
  // URLSearchParams obsługuje wiele wartości tego samego klucza,
  // co jest wymaganą formą dla position / work_mode / experience
  const params = new URLSearchParams();
  for (const [key, value] of Object.entries(filters)) {
    if (Array.isArray(value)) {
      value.forEach(v => params.append(key, v));
    } else {
      params.append(key, value);
    }
  }

  const response = await fetch(`${BASE_URL}/listings?${params}`, {
    headers: { 'Authorization': `Bearer ${API_KEY}` }
  });

  if (response.status === 429) {
    const retryAfter = Number(response.headers.get('Retry-After') || 60);
    throw new Error(`Rate limit - ponów za ${retryAfter}s`);
  }

  const body = await response.json();
  if (!body.success) {
    throw new Error(`${body.error.code}: ${body.error.message}`);
  }

  return body;
}

// Użycie
const candidates = await getListings({
  work_mode: 'remote',
  experience: ['2-5', '5+'],
  limit: 50
});

Python (requests)

import os
import requests

API_KEY = os.environ['ZATRUDNIJMNIE_API_KEY']
BASE_URL = 'https://zatrudnijmnie.pl/api/v1'

headers = {'Authorization': f'Bearer {API_KEY}'}

# Lista ogłoszeń - lista w params daje powtórzony parametr
response = requests.get(
    f'{BASE_URL}/listings',
    headers=headers,
    params={
        'work_mode': 'remote',
        'experience': ['2-5', '5+'],
        'limit': 50
    }
)
response.raise_for_status()
candidates = response.json()

# Szczegóły kandydata (wymaga contacts.read dla pola 'contact')
listing_id = candidates['data'][0]['id']
detail = requests.get(f'{BASE_URL}/listings/{listing_id}', headers=headers).json()

contact = detail['data'].get('contact')
if contact:
    print(f"Kontakt: {contact['email']}")
else:
    print("Klucz API nie ma uprawnienia contacts.read")

PHP (Guzzle)

<?php
use GuzzleHttp\Client;

$client = new Client([
    'base_uri' => 'https://zatrudnijmnie.pl/api/v1/',
    'headers' => [
        'Authorization' => 'Bearer ' . getenv('ZATRUDNIJMNIE_API_KEY')
    ]
]);

// Lista ogłoszeń
$response = $client->get('listings', [
    'query' => [
        'work_mode' => 'remote',
        'limit' => 50
    ]
]);

$candidates = json_decode($response->getBody(), true);

Wsparcie


Ostatnia aktualizacja: 31 sierpnia 2026