ZatrudnijMnie API Documentation
Version: 1.0
Base URL: https://zatrudnijmnie.pl/api/v1
Authentication: API Key (Bearer token)
Spis treści
- Wprowadzenie
- Autoryzacja
- Uprawnienia
- Rate Limiting
- CORS
- Format odpowiedzi
- Walidacja parametrów
- Endpoints
- Webhooki – payload i sygnatura
- Słowniki wartości
- Limit pobrań danych kontaktowych
- Kody błędów
- 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
- Zaloguj się do konta zespołu
- Przejdź do Ustawienia → Dostęp / API
- Kliknij "Utwórz nowy klucz"
- 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. Uprawnienielistings.readsamo w sobie nie otwiera dostępu do danych osobowych – klucz bez zapisanej sekcjicontactsotrzyma403 PERMISSION_DENIEDnaGET /listings/:id/cv, aGET /listings/:idzwróci ogłoszenie bez pólcontacticvUrl. Jeśli klucz ma korzystać z danych kontaktowych, zapisz jego uprawnienia w panelu zespołu z zaznaczonymcontacts.read.
webhooks.managejest weryfikowane dwuetapowo: klucz musi mieć to uprawnienie i organizacja musi mieć planteam_premium_plusalboteam_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:
- zmienna
API_V1_CORS_ORIGINS(originy rozdzielone przecinkami) – konfiguracja dedykowana dla publicznego API, - w razie jej braku – główna zmienna
CORS_ORIGIN, - w środowisku deweloperskim, gdy żadna nie jest ustawiona –
http://localhost:3000ihttp://127.0.0.1:3000, - w produkcji/stagingu, gdy żadna nie jest ustawiona – żaden origin nie jest dopuszczany (fail-closed).
Zachowanie:
Access-Control-Allow-Originjest ustawiane wyłącznie przy dokładnym dopasowaniu nagłówkaOrigindo 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ę statusem204bez 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 jako5%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_maxfiltrują polesalary_expectation(oczekiwania kandydata), nie widełki oferty.searchjest sanityzowane – dozwolone są litery (w tym polskie), cyfry, spacje i myślnik; pozostałe znaki są zamieniane na spacje.contract_typesprzyjmuje kody ze słownikaGET /contract-typesw dowolnej wielkości liter (praktyka=PRAKTYKA=Praktyka). Kod spoza słownika zwracaVALIDATION_ERROR.- Sortowanie
newestużywarefreshed_at(odświeżenie ogłoszenia przez kandydata), a dopiero potemcreated_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"
}
}
titleijobTitlezawierają tę samą wartość –jobTitleistnieje wyłącznie dla kompatybilności wstecznej.positionicitymogą 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 uprawnieniecontacts.read. Klucze bez tego uprawnienia otrzymują szczegóły ogłoszenia bez pólcontacticvUrl– dane osobowe nie są wtedy nawet pobierane z bazy (minimalizacja danych, RODO Art. 5(1)(c)).Gdy klucz ma uprawnienie
contacts.read, obiektcontact(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
cvUrlw odpowiedziGET /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"
}
}
successRateierrorRatesą 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"
}
}
keyPrefixto 8 znaków po przedrostkuzm_live_– służy do rozpoznania klucza w panelu.planprzyjmuje wartościteam_premium,team_premium_pluslubteam_enterprise. LicznikitotalRequests/totalErrorssą narastające od utworzenia klucza (nie podlegają parametrowidays).
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
Praktykanie jest zapisany wielkimi literami. Filtrcontract_typesdopasowuje kody niezależnie od wielkości liter, ale w polucontractTypesodpowiedzi 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_casesą przestarzałe (deprecated). Endpoint zwracał je historycznie, w odróżnieniu od reszty API używającejcamelCase. Obie formy niosą tę samą wartość i są zwracane równolegle, aby nie zepsuć istniejących integracji. Nowy kod powinien czytać wyłącznie polacamelCase– aliasysnake_casezostaną usunięte w API v2. Kształt obiektu jest teraz identyczny jak w odpowiedziPOST /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łoszenialisting.updated– aktualizacja ogłoszenialisting.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 przezGET /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.pingz payloadem:{ "test": true, "message": "To jest testowy webhook z ZatrudnijMnie", "timestamp": "2026-01-01T12:00:00.000Z" }Uwaga:
test.pingnie jest eventem subskrybowanym – jest wysyłany do wskazanego webhooka niezależnie od jego listyevents. Twój endpoint powinien go rozpoznawać i ignorować. Odpowiedźsuccess: trueoznacza, że wysyłka została zainicjowana, a nie że Twój serwer odpowiedział poprawnie – wynik sprawdź wGET /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: 0oznacza, że połączenie nie doszło do skutku (timeout, błąd DNS, odrzucone TLS) – szczegóły znajdziesz werrorMessage. Każda próba (również ponowienie) tworzy osobny wpis z własnymattemptNumber.
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
datanie jest tym samym formatem, co odpowiedźGET /listings/:id. Jest to płaski obiekt wsnake_casez ograniczonym zestawem pól. Aby pobrać pełne dane ogłoszenia (w tym opis i dane kontaktowe), wykonajGET /listings/:idz użyciemdata.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_titleodpowiada polutitlew REST API.listing.deletedjest 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, otrzymaszlisting.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
2xxnatychmiast po przyjęciu zdarzenia, a przetwarzanie wykonuj asynchronicznie – przekroczenie 10 s uruchamia ponowienie. - Zapewnij idempotencję na podstawie
X-Webhook-Id/idz payloadu: ponowienie ma ten samiddostarczenia, 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
- 📧 Email: [email protected]
- 📖 Dokumentacja: https://zatrudnijmnie.pl/docs/api
Ostatnia aktualizacja: 31 sierpnia 2026