Unified.to
All articles

How to Advertise Jobs from Any ATS with a Unified Advertising API


July 10, 2026

Recruiting and talent products that display customer jobs often need to promote them too, running the same open role as a paid campaign on the platforms where candidates are: LinkedIn, Meta, Google, and TikTok. With Unified.to you read the job from the ATS as one enriched object and act on it in the Advertising category through the same connection model, keeping the ad campaign aligned to the job as it changes. The job stays the source of truth; the campaign becomes a projection of it. No candidate or job data is stored at rest.

The hard part is not fetching a job or reading a campaign metric. It is that the job lives in one system and the campaign lives in another, each with a different schema, and keeping the two in sync means building and maintaining integrations on both sides. This guide shows how to connect the two categories so that when a role opens, changes, or closes in the ATS, the campaign follows. The examples use the Unified.to TypeScript SDK.

Which two Unified.to categories does this use case span?

This use case spans the ATS category (the job as source) and the Advertising category (the campaign as destination). The ATS job supplies the campaign's content and lifecycle; the Advertising integrations run the campaign.

CategoryRole in this flowKey objects
ATSSource of truth for the role being advertisedjob
AdvertisingRuns the paid campaign for that roleorganization, campaign, group, ad, target, report
The Advertising category defines nine objects in total. Six support full CRUD (Organization, Campaign, Group, Ad, Creative, and Insertion Order), which is what makes creating a campaign from a job possible (Path B, below). The other three are read-only: Report returns metrics, while Target and Promoted are lookups whose IDs you feed into writes. Target IDs go into targeting, and Promoted IDs (a company page, a lead form) into the ad you create. Connecting these two categories through one API and one connection model lets a product promote customer jobs without building separate integrations per ATS and per ad platform.

What does the ATS Job object give you for an ad?

The Unified.to AtsJob object returns in a single call with the fields an ad needs already embedded on the parent, rather than split across separate resources you assemble yourself. One read gives you the content, the targeting inputs, the destination, and the lifecycle signal.

Job fieldAd use
nameAd headline / campaign name
descriptionAd body copy
postings[] (posting_url, name, description, is_active)Public-facing destination and copy for the active posting
public_job_urlsAd destination / apply URL
addresses (city, region_code, country_code)Geographic targeting input
remoteTargeting or copy variant for remote roles
groups[] (type: DEPARTMENT, TEAM, DIVISION, BUSINESS_UNIT, BRANCH, SUB_DEPARTMENT)Campaign segmentation by org unit
employment_typeAudience or copy variant (full-time, contractor, intern, and the rest)
compensation[] (type, min, max, currency, frequency)Pay-range copy where the ad platform allows it
language_localeAd locale (ISO 639-1)
openings[].skillsTargeting or copy inputs
status (OPEN, CLOSED, ARCHIVED, PENDING, DRAFT) + closed_atCampaign lifecycle trigger
Two things worth reading defensively. First, prefer the active posting over the raw job where it exists: postings[] carries a public posting_url, name, and description written for candidates, which is better ad material than the internal job fields; fall back to public_job_urls and the job's name/description when no active posting is present. Second, every field above is defined in the unified model, but population still varies by integration: not every ATS supplies compensation or a structured posting. Read each field with a fallback, and use the published per-integration field support to know what a given ATS returns before you depend on it.

How do customers connect their ATS and ad accounts?

Customers authorize each integration through Unified.to's embedded authorization flow, and your application stores one connection_id per category per customer. The flow is the same on both sides:

  1. Your application launches the authorization component.
  2. The customer selects their ATS (Greenhouse, Lever, and the rest) and, separately, their ad platform.
  3. The customer authorizes access.
  4. Unified.to returns a connection_id for each.

Store the mapping user_id → connection_id for both the ATS connection and the Advertising connection, and reference the right one on each call.

import { UnifiedTo } from '@unified-api/typescript-sdk';

const sdk = new UnifiedTo({
  security: { jwt: process.env.UNIFIED_API_KEY! },
});

const atsConnectionId = '...'; // customer's ATS connection
const adsConnectionId = '...'; // customer's Advertising connection

How do you read the job from the ATS?

Read the role from the ATS job endpoint, which returns a normalized AtsJob across every supported ATS integration. Filter to open roles and use updated_gte for incremental reads once you have an initial set.

// REST: GET /ats/{connection_id}/job
const jobs = await sdk.ats.listAtsJobs({   // [FLAG: confirm SDK method name; REST path GET /ats/{id}/job confirmed]
  connectionId: atsConnectionId,
  limit: 50,
  offset: 0,
});

const openJobs = jobs.filter((j) => j.status === 'OPEN');

Each returned job carries the fields from the table above on the parent object. You do not make a second call to assemble location, department, or the apply URL; they are already on the job.

How do you turn a job into a running campaign?

There are two paths, and which one applies depends on whether the campaign already exists in the customer's ad account. Both are supported. This guide walks Path A end to end, aligning an existing campaign to the job, then shows Path B, creating a new ad from the job, with the job-to-ad field mapping worked in full.

Path A: align an existing campaign to the job (covered here). The customer's campaign already exists in the ad platform. Your product maps the ATS job to that campaign and keeps its content, targeting, and lifecycle in sync with the job. Every operation below uses read → merge → update semantics already supported across the Advertising category.

Path B: create the campaign from the job in one pass (supported, with a per-integration check). The Campaign, Group, Ad, and Creative objects support full CRUD, so you can create a new campaign from an ATS job rather than only aligning an existing one. What varies is which integrations accept programmatic creation: Unified.to publishes a write surface across 15 of its advertising integrations, but per-integration create coverage, as opposed to status or budget writes, is capability-matrix data, so confirm it for the specific ad platforms your customers run before you ship the feature. The create hierarchy is Campaign → Group → Ad → Creative, mapping the job's name and description onto the ad's copy fields and public_job_urls onto the destination.

Set targeting from the job's location

Apply targeting at the group level, which is where most advertising integrations accept it. First resolve the job's location to a targeting identifier using the lookup endpoint, then merge it into the group.

// 1) Resolve the job location to a targeting ID
const targets = await sdk.ads.listAdsTargets({
  connectionId: adsConnectionId,
  type: 'countries',
  query: 'united',
  limit: 50,
});

// 2) Read the group, merge targeting, update
const group = await sdk.ads.getAdsGroup({ connectionId: adsConnectionId, id: groupId });

await sdk.ads.updateAdsGroup({
  connectionId: adsConnectionId,
  id: groupId,
  adsGroup: {
    ...group,
    targeting: {
      geographic: { countries: ['US'] },
    },
  },
});

Targeting identifiers differ by integration (Meta uses interest IDs, Google uses geo target constants, TikTok uses category IDs), which is why you resolve them through listAdsTargets rather than passing raw values. Available targeting types vary by integration; check supported options before enabling a control in your UI. And because this is a job ad, keep targeting to broad geography. The employment-category restrictions below apply whether you are aligning an existing campaign or creating one.

Keep budget and schedule in sync

Campaign updates use PUT semantics, so read the campaign, merge only the fields you intend to change, and update. This avoids clearing fields you did not set.

const campaign = await sdk.ads.getAdsCampaign({ connectionId: adsConnectionId, id });

await sdk.ads.updateAdsCampaign({
  connectionId: adsConnectionId,
  id,
  adsCampaign: {
    ...campaign,
    budgetAmount: 5000,
    budgetPeriod: 'DAILY',
    endAt: '2026-09-30T23:59:59Z', // ISO-8601
  },
});

How do you create a new ad from the job? (Path B)

Where you are not aligning an existing campaign but building one, create the campaign, group, and ad in that order, then map the job's content onto the ad's fields. The Ad object carries the text of the ad directly (headline, ad_copy, cta, final_url), so a text or responsive job ad needs no separate creative; for an image or video ad you also create a creative and reference it from the ad. The create hierarchy is campaign → group → ad (→ creative for media).

Create the campaign first, with budget, schedule, and, because this is a job ad, the employment special ad category:

const campaign = await sdk.ads.createAdsCampaign({   // POST /ads/{connection_id}/campaign (confirmed)
  connectionId: adsConnectionId,
  adsCampaign: {
    name: job.name,
    organizationId,
    status: 'PAUSED',
    budgetAmount: 5000,
    budgetPeriod: 'DAILY',      // DAILY / MONTHLY / TOTAL / LIFETIME
    currency: 'USD',
    startAt: '2026-08-01T00:00:00Z',
    category: 'employment',     // Meta only; likely normalized, so 'employment' should work; verify with a test call ('EMPLOYMENT' if passed through)
  },
});

Then create the group, applying geographic targeting resolved from the job's addresses, an optimization goal that drives applies, and, for a job ad, no demographic targeting (see below):

const group = await sdk.ads.createAdsGroup({   // POST /ads/{connection_id}/group (confirmed)
  connectionId: adsConnectionId,
  adsGroup: {
    name: job.name,
    campaignId: campaign.id,
    status: 'PAUSED',
    optimizationGoal: 'LINK_CLICKS',   // writable on Meta / TikTok / X: LINK_CLICKS / LANDING_PAGE_VIEWS / CONVERSIONS
    targeting: {
      geographic: {
        countries: [{ id: 'US', name: 'United States' }],
      },
    },
  },
});

The group also carries bid controls (bid_amount, bid_strategy), pacing, and billing_event, and budget can sit at the group level as well as the campaign level depending on the integration; set these where you need them, or rely on the platform defaults. For a lead-generation job ad, attach a LEAD_FORM_ID promoted entity on the ad so candidates apply in-platform; lead-gen optimization itself is a platform-specific goal passed through where the integration supports it, since the cross-platform optimization_goal values on create are REACH, IMPRESSIONS, LINK_CLICKS, LANDING_PAGE_VIEWS, and CONVERSIONS.

Then create the ad, mapping the job onto confirmed Ad fields. Prefer the active posting's public copy over the raw job:

const activePosting = job.postings?.find((p) => p.isActive);

const ad = await sdk.ads.createAdsAd({   // POST /ads/{connection_id}/ad (method + signature confirmed)
  connectionId: adsConnectionId,
  adsAd: {
    name: job.name,
    campaignId: campaign.id,
    groupId: group.id,
    adType: 'RESPONSIVE',                                  // platform-dependent (TEXT / IMAGE / VIDEO / RESPONSIVE / SHOPPING)
    headline: activePosting?.name ?? job.name,
    adCopy: activePosting?.description ?? job.description,
    cta: 'Apply',
    finalUrl: activePosting?.postingUrl ?? job.publicJobUrls?.[0],
    status: 'PAUSED',                                      // create paused, activate after review
  },
});

Create the ad with status: 'PAUSED' and activate it after a review step, so a malformed mapping never spends live budget. ad_type is platform-dependent, so set it from the target integration rather than a fixed default.

To attach a promoted entity, look it up through the read-only Promoted endpoint and pass it in the ad's promoted array as { id, type }. For a job ad the useful types are PAGE_ID (run the ad from the employer's page) and LEAD_FORM_ID, for a lead-gen job ad that captures candidate interest in-platform instead of sending the click to an external apply URL.

For an image or video job ad rather than a text one, create a creative and reference it from the ad via creativeIds. The Creative object carries its own copy and asset fields, so the job maps onto it directly:

const creative = await sdk.ads.createAdsCreative({   // POST /ads/{connection_id}/creative (confirmed)
  connectionId: adsConnectionId,
  adsCreative: {
    name: job.name,
    campaignId: campaign.id,
    groupId: group.id,
    creativeType: 'STANDARD',                              // STANDARD / EXPANDABLE / VIDEO / NATIVE
    title: activePosting?.name ?? job.name,
    body: activePosting?.description ?? job.description,
    cta: 'Apply',
    linkUrl: activePosting?.postingUrl ?? job.publicJobUrls?.[0],
    assetUrls: [yourHostedImageUrl],                       // your creative asset
    status: 'PAUSED',
  },
});
// pass creativeIds: [creative.id] on the ad above when using a media creative

Full CRUD is defined on the object model, but per-integration create coverage is capability-matrix data, not a schema guarantee. Confirm the integrations your customers run accept programmatic campaign creation before you ship. Where a platform needs a field the unified model doesn't carry, each create call takes a raw parameter that passes provider-specific fields straight through.

Which fields are writable, on which platforms?

Writability varies by integration, and the biggest variation is where the ad copy lives: some platforms expose it on the Ad, others on the Creative. On LinkedIn, Microsoft Advertising, and Pinterest the copy is writable on the Ad (headline, ad_copy); on Meta it is writable on the Creative (title, body); Google Ads and TikTok split across both. So the create flow is the same shape everywhere (campaign, then group, then ad, plus a creative for media), but you set the copy on whichever object the target integration marks writable, and you read that from Unified.to's published field-support matrix rather than assuming. The employment category field is writable through Unified.to on Meta; on other platforms you satisfy their employment-ad requirements through their own setup and the raw passthrough.

The controls that stay constant across the major job-ad platforms are campaign status, budget, and group targeting, which is why Path A (aligning an existing campaign) is the portable path and Path B (full creation) is the more platform-shaped one.

Writable fieldLinkedInMetaGoogle AdsTikTokMicrosoft
Campaign status (pause/resume)
Campaign budget
Group targeting
Employment categorynononono
Ad headline / copynoheadlinecopy
Creative title / bodyno
Group optimization goalnonono
Support is writable-field data from Unified.to's published matrix and varies as integrations change; treat the live matrix as canonical. Of the 20 advertising integrations, 15 expose writable fields; the rest are read-only for now.

Do job ads have targeting restrictions?

Yes. Employment ads are a restricted category, and on Meta, Unified.to's campaign category field is where you declare it. Meta classifies housing, employment, and credit ads as Special Ad Categories; Google enforces an equivalent employment-ads policy. Declaring the category is mandatory for job ads, and it removes age, gender, and narrow geographic targeting as options, following the anti-discrimination rules from the 2019 U.S. housing-and-employment settlements. Through Unified.to the category field is writable on Meta; on Google and other platforms you satisfy the equivalent requirement through their own campaign setup or the raw passthrough. Either way, do not populate the demographic targeting object (age_min, age_max, male, female) or tight radius targeting for a job ad, even though the fields exist in the model for non-restricted campaigns.

The practical rule when mapping a job to an ad: use the job's addresses for broad geographic targeting and its groups[]/openings[].skills for role context, but never map anything that resolves to a protected demographic characteristic. The unified model exposes the full targeting surface; employment law, not the API, is what constrains which parts of it you may use here

How does closing a role pause its campaign automatically?

Subscribe to the ATS job webhook, and let a job update drive the campaign's lifecycle. When a role closes in the ATS, Unified.to delivers an ats_job_updated event with the changed record; your handler reads the new status and pauses the campaign. The ATS job webhook is the campaign controller: the recruiter closes the role once, in the system they already use, and the paid campaign follows.

"When a vendor offers real webhooks, we pass the events straight through. When they don't, we detect the changes and deliver the events ourselves. Same interface either way, and you never build polling." — Roy Pereira, CEO of Unified.to

Subscribe to job updates:

{
  "hook_url": "https://your-app.com/webhooks",
  "connection_id": "<ats_connection_id>",
  "object_type": "ats_job",
  "event": "updated"
}

On delivery, act on the status transition:

// Inside your webhook handler, after signature validation
if (job.status === 'CLOSED') {
  const campaign = await sdk.ads.getAdsCampaign({ connectionId: adsConnectionId, id: mappedCampaignId });
  await sdk.ads.updateAdsCampaign({
    connectionId: adsConnectionId,
    id: mappedCampaignId,
    adsCampaign: { ...campaign, status: 'PAUSED' },
  });
}

A closed role arrives as an ats_job_updated event where status moves to CLOSED (or ARCHIVED), carrying a closed_at timestamp, rather than as a separate event, so treat the status transition as the trigger. For ATS integrations without native webhooks, Unified.to detects changes on a configurable interval, as frequent as every minute, so the campaign pauses within that detection window rather than the instant the recruiter clicks close. Virtual webhooks deliver created and updated events and suit cases where a short delay is acceptable. Unified.to retries failed webhook deliveries with backoff, so make your handler idempotent: a repeated delivery should not double-pause or error. Reopen the role and the same mechanism resumes the campaign; change the job's location and you re-run the targeting lookup and update the group.

Which operations are supported on which integrations?

Write support is a property of the underlying ad platform, not of the unified layer, so it varies by integration and Unified.to surfaces that difference rather than hiding it. Campaign status writes (pause and resume) are supported across most advertising integrations; where a specific write is not supported, Unified.to returns 501 Not Implemented rather than failing silently.

Handle the differences explicitly:

  • Targeting is group-level on most integrations; only some accept campaign-level targeting.
  • Budget and flight-date writes are supported where the ad platform allows post-launch edits; some do not.
  • Advertising webhooks exist for four integrations only: Meta Ads, Google DV360, Snapchat, and TikTok Ads emit native webhooks, while the rest are polling. This flow does not depend on ad-side webhooks; the lifecycle trigger is the ATS job webhook, which covers every ATS integration.
  • 501 responses mean the operation is unsupported for that integration. Do not retry them.
  • 429 responses mean a rate limit was hit. Retry with exponential backoff and jitter; the SDK does not retry writes for you.

Why run this through Unified.to instead of building both integrations?

Because the alternative is maintaining integrations on both the ATS side and the ad-platform side, and a mapping layer between them, per customer. Unified.to gives you a normalized AtsJob across 108 ATS integrations and a consistent campaign, group, and targeting model across 20 advertising integrations, reached through one connection model and a pass-through architecture that stores no customer job data at rest. You map the job to the campaign once; the ATS webhook keeps them aligned after that. That is infrastructure, not integration maintenance.

Building the flow: the sequence

  1. Customer authorizes an ATS connection and an Advertising connection through the embedded authorization flow.
  2. Read open roles with listAtsJobs, filtering on status === 'OPEN'.
  3. Either map each job to an existing campaign (Path A), or create a campaign, group, and ad from the job with createAdsAd mapping headline, ad_copy, cta, and final_url (Path B).
  4. Resolve the job's location with listAdsTargets and apply it group-level via updateAdsGroup.
  5. Keep budget and schedule aligned with updateAdsCampaign using read → merge → update.
  6. Subscribe to the ats_job updated webhook and pause the campaign when status moves to closed.
  7. For job ads, set the campaign category to employment and keep targeting to broad geography, with no demographic targeting.
  8. Handle 501 (unsupported, do not retry) and 429 (rate limit, back off) explicitly.

Start a free 30-day trial or book a demo.

Frequently asked questions

Can you post an ATS job as an ad automatically with Unified.to? Yes. You can create a new campaign from an ATS job, because Unified.to's Campaign, Group, Ad, and Creative objects support full CRUD, and you can also keep an existing campaign aligned to a job. Programmatic creation is available within Unified.to's write surface, which spans 15 of its advertising integrations; confirm create support for the specific ad platforms your customers run, since per-integration write coverage varies.

How does closing a role in the ATS stop its ad campaign? Unified.to delivers an ats_job_updated webhook event when a role's status changes to a closed state, and your handler pauses the mapped campaign with updateAdsCampaign. The ATS job is the source of truth and the ad campaign follows it, so the recruiter only acts once, in the ATS.

Which fields from a Unified.to ATS job map to an ad?name maps to the headline, public_job_urls (or an active posting's posting_url) to the ad destination, addresses to geographic targeting, and groups[] to campaign segmentation by department or team. description, compensation (type, min, max, currency, frequency), employment_type, and language_locale are all first-class fields on the Job model; their population varies by integration, so read each with a fallback.

Does advertising campaign control work the same on every ad platform? No. Campaign status, budget, and group targeting are writable across the major job-ad platforms (LinkedIn, Meta, Google Ads, TikTok, Microsoft), but ad copy and other fields vary: copy is writable on the Ad for LinkedIn, Microsoft, and Pinterest, and on the Creative for Meta, while the employment category field is writable only on Meta. Unified.to returns 501 Not Implemented where an operation is unsupported, and publishes a per-integration field matrix so you can check before you build. Of its advertising integrations, 15 expose writable fields and the rest are read-only.

Do employment ads have targeting restrictions I need to handle? Yes. Major ad platforms treat employment ads as a restricted category: Meta as a Special Ad Category and Google under its employment-ads policy, which removes age, gender, and narrow geographic targeting to comply with anti-discrimination rules. Declare it with Unified.to's campaign category field set to employment, and do not populate demographic targeting for job ads; verify the current requirements against each platform's policy before shipping.

Does Unified.to store the job or campaign data? No. Unified.to runs a pass-through architecture: requests route to the source ATS and ad platforms at request time, and no customer job or campaign records are stored at rest. Only credentials and minimal operational metadata persist.


Written for Unified.to by Mallory J Greene.

About the author: Mallory Greene writes about generative engine optimization (GEO) and B2B content strategy through her practice, Search Everywhere. She has covered unified APIs and integration infrastructure across Unified.to's technical content library. Based in Toronto.

All articles