---
title: "Create Recruitment Campaign Watchlist signal"
method: POST
path: "/api/v1/signals/recruitment-campaign-watchlist"
tags: ["Signals"]
---

# Create Recruitment Campaign Watchlist signal

`POST /api/v1/signals/recruitment-campaign-watchlist`

## Request

- Base URL: `https://api.rodz.io` (the document declares no server; this is the origin it was published from)
- URL: `https://api.rodz.io/api/v1/signals/recruitment-campaign-watchlist`
- Auth: HTTP bearer

Watch a fixed list of companies and get told when one of them starts hiring in VOLUME - the moment an account staffs up is the moment budget and headcount have just been approved, which is usually the same meeting your budget comes out of.

Two signals sit either side of this one. The Recruitment Campaign signal DISCOVERS companies from filters, where this one starts from your list. The Job Offers Watchlist fires on any single new posting at a company you watch, where this one fires only on a hiring PUSH - a company qualifies only once it has at least `minJobCount` open roles matching your filters inside the `publishedDate` window. That threshold is the whole point: one open role is noise, ten is a plan.

**Input:** `companies` (a name, a domain, or a LinkedIn company URL per entry, or an object `{name, domain, linkedinUrl}` carrying all three), `minJobCount` and optional `maxJobCount` (the volume band that qualifies a company), `publishedDate` (the recency window the roles must fall in, e.g. `30d`), plus the usual `jobTitleInclude` / `jobTitleExclude`, `locations` and `companySizeFilters` filters, optional persona targeting (`enablePersonaTargeting`, `targetPersonas`, `companyOnlyWhenContactsRefused` - default false, still deliver the company, with no contact, when every candidate found there was filtered out instead of nothing, `requireContact` - default false; true = only leads with a contact: a company where nobody matching your personas is found is neither delivered nor charged) and enrichment flags (`getEmails`, `getPhones`).

**Output:** one COMPANY-level lead per qualifying company - not one per posting. Each is pushed to your `webhookUrl` with a `signal` object carrying one of the open roles as the evidence, a `company` object, and `contact` empty. Turn persona targeting on and the decision-makers matching your `targetPersonas` are resolved at each company and delivered as person-leads instead.

Only companies that resolve to a WEBSITE are watched: the filtering happens at the job-data source, which matches companies on their domain. An entry given as a bare name starts contributing once its domain resolves, and until then it is listed but not matched. `GET /api/v1/watchlists/{signalId}/companies` is the one place that shows what is actually being watched - check it first if the signal delivers less than you expected.

`companies` is consumed into the watchlist, so it is NOT echoed back in the `config` of this or any later response - the watched list has exactly one home. Add or remove companies with `POST` / `DELETE` on `/api/v1/watchlists/{signalId}/companies`; an edit re-scopes what is monitored automatically, with no need to recreate the signal.

**Use cases:**
- Budget-approved timing on named accounts - a company hiring ten roles at once has just had headcount signed off, which is the same meeting your budget comes out of; the volume is the qualifier a single posting cannot give you.
- Scaling customers, not just new ones - watch your own base and treat a hiring push as the moment a team outgrows the plan it is on, ahead of the renewal conversation.
- Sell to the build-out - recruiters, staffing, onboarding, equipment, workspace and payroll all have a buying window that opens when the campaign starts, not when the seats are filled.
- Competitor momentum - a competitor staffing a whole function tells you where they are betting, early enough to answer it.

## Request body

- object
  - `name` string, required: Name for this signal
  - `webhookUrl` string, nullable: Where each lead is POSTed as it is produced. Optional (since 2026-09-23): omit it when you read leads with GET /api/v1/signals/{signalId}/leads or route them to a connected CRM or sequencer with `destinations` on PATCH /api/v1/signals/{signalId}.
  - `config` RecruitmentCampaignWatchlistConfig, required: Watch a named list of companies and get told when one starts hiring in volume. Same filters as the Recruitment Campaign signal, applied to your own list of accounts instead of the open market. A company qualifies once it has at least ``minJobCount`` matching roles open inside the ``publishedDate`` window. Companies are given in ``companies`` as a name, a domain, or a LinkedIn company URL.
    - `sourceType` 'csv' | 'hubspot_list' | 'pipedrive_filter' | 'hubspot_company_list' | 'pipedrive_org_filter', nullable: Where the watched list comes from. Omit it to use the list you send inline in this request. Set it to read a list that already exists in your connected CRM instead — the connection itself is made in the dashboard. Call `GET /api/v1/watchlists/sources` for the lists available on your account.
    - `sourceListId` string, nullable: Id of the CRM list or filter to read, as returned by `GET /api/v1/watchlists/sources`. Required when `sourceType` is set, ignored otherwise.
    - `sourceDynamicSync` boolean: Keep the watched list in step with the CRM list: entries added there start being watched, entries removed stop. Set false to take a one-off copy at creation and leave it frozen. Has no effect without `sourceType`.
    - `enablePersonaTargeting` boolean: Enable persona-based targeting
    - `targetPersonas` string[]: Filter by job-role persona (decision-maker targeting). Accepted values are the persona display names listed here; see GET /v1/personas for the full taxonomy with department, seniority and EN/FR job titles. Titles are matched by meaning, not as text: `targetPersonas` and `customPersonaTitles` form ONE list and a person qualifies by matching ANY entry. Each entry is read as a function plus a seniority level, in any language ("Directeur Commercial" satisfies "Sales Director", "Président" or "Directeur général" a CEO target), and a role at that level or above qualifies: a Director or Head target refuses a plain Manager; a founder running the company counts as CEO. A custom title carries the level its own words state; one with no level word accepts every level. Valid values: "Account Executive", "Account Manager", "Accountant", "Agency Director", "Business Analyst", "Business Developer", "Business Unit Director", "Buyer", "CEO", "CIO", "CSR Director", "CSR Manager", "CTO", "Communication Director", "Community Manager", "Content Manager", "Copywriter", "Creative Director", "Customer Success Director", "Customer Success Manager", "Customer Support Agent", "Cybersecurity Director", "Data Analyst", "Data Director", "Designer", "DevOps Engineer", "Digital Director", "Digital Project Manager", "Ecommerce Director", "Ecommerce Manager", "Engineering Manager", "Engineering Office Director", "Ergonomist", "Executive Assistant", "Finance Director", "Finance Manager", "GTM Director", "GTM Manager", "Growth Director", "Growth Manager", "HR Assistant", "HR Development", "HR Director", "Happiness Manager", "IT Project Manager", "Industrial Director", "Innovation Director", "Lab Manager", "Legal Counsel", "Legal Director", "Legal Manager", "Logistics Coordinator", "Logistics Director", "M&A Director", "Marketing Director", "Marketing Manager", "Office Manager", "Omnichannel Director", "Operations Director", "Operations Manager", "Payroll Manager", "Payroll Officer", "Procurement Specialist", "Product Director", "Product Manager", "Project Manager", "Purchasing Director", "Purchasing Manager", "Quality Health and Safety", "Quality Manager", "Quality Technician", "R&D Director", "R&D Engineer", "R&D Manager", "Recruitment Manager", "Research Director", "Research Scientist", "SDR / BDR", "Sales Director", "Sales Manager", "Social Media Director", "Social Media Manager", "Software Engineer", "Supply Chain Director", "Support Director", "Support Manager", "Talent Acquisition", "Technical Director", "Training Director", "Training Officer", "Transformation Director", "UX Designer", "Warehouse Director", "Web Developer"
    - `personaExportMode` string: Which matched contacts to deliver per company. "top_ranked" (default) delivers the best-ranked contacts; "all_personas" delivers contacts matching any of your personas. "maxLeadsPerCompany" caps the number of contacts per company in either mode (1 when omitted).
    - `maxLeadsPerCompany` integer, nullable: Maximum number of leads to create per company; 1 when omitted. On job offer, republished job offer and recruitment campaign signals it is the maximum number of contacts per company per day, and `dailyLeadLimit` counts companies: with a limit of 1 and a maximum of 10, you receive at most 10 contacts from one company per day. A company is delivered at most once a day, however many jobs it posts.
    - `personaId` string, nullable: Legacy single persona ID
    - `personaLocations` string[]: Where to look for contacts, e.g. `["France", "Germany"]`. With `personaLocationsOnly` true, it alone decides which contacts are accepted (empty = anywhere) and `locations` only targets where the job is, so a job in Germany can come with a contact based in another country you list here. With `personaLocationsOnly` false (the default), contacts must also be located in one of `locations`. Pass names, as for `locations`.
    - `personaExcludedLocations` string[]: Where the contacts must NOT be located. With `personaLocationsOnly` true and only exclusions set, contacts anywhere except these places are accepted. With `personaLocationsOnly` false, `excludedLocations` also applies to contacts.
    - `customPersonaTitles` string[]: Custom free-text job titles to target in addition to predefined personas
    - `excludedTitles` string[]: Job titles to exclude. Matched as whole words, case- and accent-insensitive: "intern" excludes "Intern" and "Interns", never "International"; a phrase matches the same words in the same order; end a word with * to match a prefix ("infirmi*" excludes "Infirmière").
    - `requireContact` boolean: Only deliver leads with a contact. Off (default): when the people search finds nobody matching your personas at a company, you still receive the company alone, with no contact, priced as a company-level lead. On: that company is neither delivered nor charged, and it does not count toward `dailyLeadLimit`. Takes precedence over `companyOnlyWhenContactsRefused` where both are set. Only applies when `enablePersonaTargeting` is on.
    - `jobTitleInclude` string[]: Keywords the job title must include. Each keyword is matched anywhere in the title, not against the whole title: `sales` matches "Head of Sales EMEA". A multi-word keyword must appear with its words in that order (`head of sales` does not match "Head of Inside Sales"). Case and accents are ignored. Several keywords match when ANY one is found, or ALL of them with `jobTitleIncludeAll`.
    - `jobTitleExclude` string[]: Keywords the job title must not include: a job is skipped when its title contains any of them anywhere (`intern` skips "Sales Intern"). Case and accents are ignored.
    - `jobTitleIncludeAll` boolean: Require EVERY `jobTitleInclude` keyword in the title instead of any one.
    - `jobDescriptionInclude` string[]: Keywords the job description must include, each matched anywhere in the description text (`Salesforce` matches a posting asking for "experience with Salesforce CRM"). A multi-word keyword must appear with its words in that order. Case and accents are ignored. Several keywords match when ANY one is found, or ALL of them with `jobDescriptionIncludeAll`.
    - `jobDescriptionExclude` string[]: Keywords the job description must not include: a job is skipped when its description contains any of them anywhere. Case and accents are ignored.
    - `jobDescriptionIncludeAll` boolean: Require EVERY `jobDescriptionInclude` keyword in the description instead of any one.
    - `jobTitleQuery` string, nullable: Advanced: a boolean search string applied to the job title instead of `job_title_include` / `job_title_exclude` (operators: AND, OR, NOT, parentheses, quoted phrases — e.g. `("head of marketing" OR "VP marketing") AND NOT intern`). When non-empty, the include/exclude title lists are ignored. Leave null for the simple list-based mode.
    - `jobDescriptionQuery` string, nullable: Advanced: a boolean search string applied to the job description instead of `job_description_include` / `job_description_exclude` (same operator syntax as `job_title_query`). When non-empty, the include/exclude description lists are ignored. Leave null for simple list-based mode.
    - `locations` string[]: Job locations to search, e.g. `["France", "Belgium"]`. Applies to the JOB's location, not the hiring company's headquarters: a job in France at a company headquartered abroad matches France. Optional — **leave it empty or omit it to search worldwide**. Multiple locations are OR'd, so adding more widens the search rather than narrowing it. Pass names, not ids (`"France"`); unrecognised names are rejected with 400 `INVALID_LOCATION`. A worldwide signal can match far more jobs than a country-scoped one, so set `dailyLeadLimit` to what you actually want delivered. On job signals `dailyLeadLimit` counts companies: each company is delivered at most once a day, however many jobs it posts. Contacts: unless `personaLocationsOnly` is true, contacts must also be located in one of these locations; set `personaLocationsOnly` with `personaLocations` to choose where contacts are, independently of where the job is.
    - `excludedLocations` string[]: Job locations to exclude from results
    - `companySizeFilters` string[]: Company size filter. Values: "Self-employed", "1-10", "11-50", "51-200", "201-500", "501-1000", "1001-5000", "5001-10000", "10001+". A company qualifies when its declared size band OR its employee count falls in a selected band; a count within 10% of a band edge counts for both neighbouring bands, and a company declaring 501+ employees is never placed in a smaller band by a low count. The delivered `employee_range` / `employee_count` are the pair the lead was judged on.
    - `excludedCompanySizeFilters` string[]: Company sizes to exclude from results
    - `industryFilters` string[]: Only return jobs at companies in these industries. Uses LinkedIn industry names, the same vocabulary as every other signal (e.g. 'Software Development', 'Hospitality', 'Staffing and Recruiting'). Empty = all industries. Call GET /api/v1/signals/industries for the valid list; names must match exactly.
    - `companyIndustryExcluded` string[]: Exclude companies in these industries — typically agencies and consultancies posting on a client's behalf. Same LinkedIn industry vocabulary as `industryFilters`. To exclude named companies rather than whole industries, use `excludedWebsites`. Defaults to recruitment agencies and consultancies.
    - `excludedWebsites` string[]: Company website domains to exclude from results — e.g. companies you already work with. Accepts domains or URLs (normalized to the bare domain, like 'acme.com'); matching is case-insensitive and ignores 'www.'.
    - `publishedDate` string: How recently the jobs must have been posted. Use "24h", or a count followed by a unit — "7d" / "30d" (days), "2w" (weeks), "3m" / "12m" (months); the spelled-out forms ("30 days", "3 months") are accepted too. Defaults to "30d" for campaigns.
    - `getEmails` boolean: Enable email enrichment (0.5 credit/lead, charged only on success)
    - `getPhones` boolean: Enable phone enrichment (5 credits/lead, charged only on success)
    - `getSireneData` boolean: Enrich with French SIRENE registry data (1 credit/lead, charged only on success)
    - `detectTechnologies` boolean: Detect technologies used on the company's public website (1 credit/lead, charged only on success)
    - `getHomepageContent` boolean: Fetch the company's homepage content (1 credit/lead, charged only on success)
    - `generateAiEmails` boolean: Generate AI-predicted emails
    - `companies` union[]: The companies to watch. Each entry is either a plain string — a name, a domain, or a LinkedIn company URL (e.g. `"Doctolib"`, `"payfit.com"`, `"https://www.linkedin.com/company/lemlist"`) — or an object `{name, domain, linkedinUrl}` (all optional, at least one required) such as `{"name": "PayFit", "domain": "payfit.com", "linkedinUrl": "https://www.linkedin.com/company/payfit"}`. Both forms can be mixed in one list. Send every identifier you have: each is another way to recognise the company, so an object matches more of your list than any single string. A domain matches most reliably; a name has to match exactly once punctuation and legal suffixes are ignored, so `"ACME, Inc."` and `"Acme"` are the same company but `"Nova"` and `"Nova Systems"` are not. **Accepted entry forms:** a company name (`"Doctolib"`); a domain or website URL (`"payfit.com"`, `"https://www.payfit.com/fr"`); its LinkedIn company page (`"https://www.linkedin.com/company/payfit"`, in any spelling: with or without `https://` and `www.`, a country subdomain, a trailing path or query; school and showcase pages too); its numeric LinkedIn company id (`"10426145"`) or a Sales Navigator company link (`"https://www.linkedin.com/sales/company/10426145"`), both read as that id; or an object `{name, domain, linkedinUrl}` combining several. Any other LinkedIn link names no company: a person's profile (`/in/`), a post or a group page is refused with 422 `INVALID_COMPANY_ENTRY`, which lists each offending entry, and nothing is saved. Consumed into the watchlist on create, so it is NOT echoed back in `config` — the watched list has exactly one home. Sent again in a `PATCH`, the entries are ADDED to the list (nothing is removed). Read it with `GET /api/v1/watchlists/{signalId}/companies`, and add, replace or remove entries with `POST` / `DELETE` on the same path.
      - union
        - string
        - WatchedCompanyObject: One company, with everything you know about it. The object form of a company-list entry. Send every identifier you have: each one is another way to recognise the company, and together they separate two companies that share a name.
          - `name` string, nullable: The company name as you know it, e.g. `"PayFit"`.
          - `domain` string, nullable: The company website, e.g. `"payfit.com"`. A full URL is accepted and reduced to its domain.
          - `linkedinUrl` string, nullable: The company LinkedIn page, e.g. `"https://www.linkedin.com/company/payfit"`. The most precise identifier of the three. A Sales Navigator company link (`/sales/company/<id>`) or the numeric LinkedIn company id is accepted too, and so are school and showcase pages; any other LinkedIn link (a person's profile, a post) is refused with 422 `INVALID_COMPANY_ENTRY`.
    - `companyOnlyWhenContactsRefused` boolean: Deliver the company alone, with no contact, when people were found at it but every one was filtered out by your persona, location or company size settings. Off by default: that company produces nothing, the same as before this option existed. On: you still get the company — priced and delivered exactly like a company-only lead, the same as when nobody at all is found there. Only applies when `enablePersonaTargeting` is on.
    - `personaLocationsOnly` boolean: Judge the contact's location by `personaLocations` and `personaExcludedLocations` only, while `locations` keeps targeting where the job is; an empty `personaLocations` then accepts contacts anywhere. Default false: contacts must also be located in one of `locations` and outside `excludedLocations`.
    - `minJobCount` integer: Minimum number of matching open positions a company must have to qualify. Required when the signal is active.
    - `maxJobCount` integer: Maximum matching open positions; companies with more than this are excluded.
  - `dailyLeadLimit` integer: Max leads per day
  - `status` string, nullable: Initial status (default: draft).

## Response `201`

Signal created

- object: Create response for the two watchlist signals. A separate model rather than an optional field on ``PublicSignalResponse``: this spec inlines the response schema at every signal endpoint (36 of them), so carrying the extra object on the shared model documented a phantom ``watchlist`` field on 34 endpoints that never return one — and doubled the size of the published spec to say so.
  - `id` string, required
  - `name` string, required
  - `status` string, required
  - `signalTypeId` string, required
  - `webhookUrl` string, nullable
  - `config` object, nullable: Signal-specific configuration
  - `dailyLeadLimit` integer, required
  - `leadsToday` integer, required
  - `leadsTotal` integer, required
  - `duplicateHandling` string, required: Duplicate lead handling: 'refuse' (never accept), 'allow' (always accept), or 'Xd' (accept after X days)
  - `createdAt` string, required
  - `updatedAt` string, required
  - `lastLeadAt` string, nullable
  - `applied` object: Settings this signal runs with where they differ from `config`, keyed like `config`. `{}` when every stored setting is applied as stored. A `null` value means the stored field is not used by this signal. Every entry is explained by a warning naming the same field.
  - `warnings` SignalConfigWarning[]: What you should know about this configuration: values applied differently from what is stored, and filter values that are probably not what you meant. Computed from the stored configuration, so they appear on GET as well as on create and update - a signal created before these fields existed reports them too. Never an error: the signal is created or updated regardless.
    - `code` string, required: Stable identifier. `VALUE_BELOW_MINIMUM` (a value under the documented minimum is applied as the minimum), `VALUE_ADJUSTED` (a value the search cannot use as written is applied as the nearest one it can, e.g. a `publishedDate` of 14d searches 15 days), `FIELD_NOT_USED` (a stored field this signal ignores), `UNKNOWN_SIZE_NOT_ACCEPTED`, `CATEGORY_WITH_OWN_INDUSTRIES` (a category listed together with industries it already covers), `UNKNOWN_INDUSTRY` (a value matching no published industry), `UNKNOWN_FIELD` (a config key this signal does not have), `UNKNOWN_TOOL` / `UNKNOWN_CATEGORY` (a tracked tool or category name that is not in the catalog of GET /api/v1/signals/technographic-tools; on a GMB Discovery signal, a `category` that is not a Google Maps category, so the signal matches no business), `UNKNOWN_CITY` (GMB Discovery: cities in `cities` that are not in the signal's `country` — `values` lists them; when none is, the signal matches no business until `cities` or `country` is changed; never an error, nothing stored is changed), `NO_FOLLOWER_SOURCE` (a Company Followers signal with no follower extraction set up yet, so it produces nothing), `DAILY_LIMIT_PENDING` (a job signal's `dailyLeadLimit` changed less than a day after its search was last rebuilt: the search keeps its previous volume until the time in `values`, while the leads delivered per day already follow the new limit), `WATCHED_LIST_EMPTY` (a watchlist reading its list from your CRM holds no company or person — the CRM is not connected, the list could not be read, or it is empty — so it produces nothing until the list fills; cleared on its own once it does), `QUOTE_REQUIRED` (an `active` Company Followers signal in `icp` mode whose follower extraction has not been priced and accepted, so it produces nothing yet: `values[0]` says why — `not_requested`, `awaiting_acceptance` (then `values[1]` is the quoted price in credits), `expired`, `declined`, `failed`, or, after an automatic quote, `auto_quote_refused` / `auto_accept_refused` (then `values[1]` is the error code); the message names the call to make — see POST /api/v1/signals/{signal_id}/extraction/quote; cleared once a quote is accepted), `WATCHED_LIST_IMPORTING` (a company watchlist whose HubSpot list is still being read: companies are added as they arrive; `values[0]` is the number of members read so far, `values[1]` the list id; cleared once the whole list is read), `MATCHING_CONFIRMATION_REQUIRED` (a New Decision Maker or Account watchlist whose companies must be matched to their LinkedIn company page before they can be watched, at a cost above the amount we match without asking: nothing is matched or charged until you confirm with POST /api/v1/watchlists/{signalId}/companies/confirm-matching; `values[0]` is the maximum cost, `values[1]` the number of companies to match; cleared once confirmed), `DESTINATION_UNAVAILABLE` (the outreach campaign this signal sends leads to cannot receive them — not found, archived, ended or without any step — so leads are not reaching it: `values[0]` is `campaign_not_found`, `campaign_archived`, `campaign_ended` or `campaign_no_steps`; cleared once a lead reaches the campaign or the signal's destination is changed), `DESTINATION_NEEDS_CONTACT` (the signal delivers companies with no contact and a destination in `destinations` only accepts people, so none of its leads reach that destination), `DESTINATION_SKIPS_COMPANY_ONLY` (some leads are companies with no contact, e.g. no one matching your personas was found, and a destination only accepts people), `DESTINATION_NEEDS_EMAIL` (a destination only accepts leads with an email and `getEmails` is off) — for these three, `values[0]` is the destination key in `destinations`, the next values are the fixes: `add_persona` (turn on `enablePersonaTargeting` with `targetPersonas`), `require_contact` (turn on `requireContact`), `add_company_destination` (add a destination that accepts companies: `hubspot`, `pipedrive`, `googleSheets` or `customWebhook`), `enable_email_finding` (turn on `getEmails`); severity `warning` when those leads reach no destination at all, `info` when another destination still receives them; never blocks the save; `COMPANY_PAGE_UNAVAILABLE` (a Company Followers signal whose extraction stopped because a page in `linkedinCompanyUrls` does not exist on LinkedIn: nothing was extracted or charged; `values` lists the pages; replace them with PATCH /api/v1/signals/{signal_id}, then start the extraction again; cleared once the pages are replaced or a new extraction runs), `COMPANY_PAGE_UNVERIFIED` (create / PATCH responses only: a page in `linkedinCompanyUrls` could not be verified at that moment and was accepted; `values` lists them — if one does not exist, its extraction stops without using credits and the signal carries `COMPANY_PAGE_UNAVAILABLE`).
    - `severity` string, required: `info`: nothing to do. `warning`: probably not what you meant.
    - `field` string, required: The `config` key concerned, camelCase (or `dailyLeadLimit`).
    - `message` string, required
    - `values` string[]: The offending values, when there are some.
  - `watchlist` object, nullable: What the watchlist create actually took in. Returned only for watchlist signals. The rejected/unresolved counts are surfaced rather than swallowed: a watchlist that quietly dropped half its list looks identical to a healthy one until the leads never arrive.
    - `watched` integer, required: Entries now being watched by this signal (people, or companies).
    - `rejected` integer: Entries that could not be used — a LinkedIn URL that is not a personal profile, or a company row with neither a name nor a domain.
    - `alreadyWatched` integer: Entries already on this signal's list, left unchanged.

## Other responses

- `400`: Invalid request: missing required config, or an unrecognized location filter
- `401`: Missing or invalid API key
- `402`: Account has no credits
- `422`: Validation Error
- `429`: Rate limit exceeded. Retry after the number of seconds in the `Retry-After` response header.

---

[API](https://skmtc.dev/rodz/apis/rodz-api.md) · [All operations](https://skmtc.dev/rodz/apis/rodz-api/llms.txt) · [OpenAPI document](https://skmtc.dev/rodz/apis/rodz-api/revisions/3003505ff896?raw)
