Unified.to
All articles

How to Advertise ATS Jobs on LinkedIn, Meta, Google and TikTok with

·

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 follows it. Unified.to keeps no customer records at rest; connection credentials and operational metadata are encrypted 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 ten objects (as of 20 October 2026). Six support full create, read, update and delete (Organization, Campaign, Group, Ad, Creative and Insertion Order), which is what makes creating a campaign from a job possible (Path B, below). Asset supports list, retrieve and create. Report, Target and Promoted 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) on the group for Meta Ads and Google Ads, or on the ad for X Ads and Amazon Advertising (as of 19 October 2026). Connecting these two categories through 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)
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 varies by integration. As of 19 October 2026, addresses come back on 119 of 152 job integrations, compensation on 91, public_job_urls on 69, language_locale on 24, postings on 18 and openings on 7. 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 the same AtsJob shape on 152 ATS integrations (as of 19 October 2026). Filter to open roles, and use updated_gte for incremental reads where the integration supports it (63 of 152).

// REST: GET /ats/{connection_id}/job
const jobs = await sdk.ats.listAtsJobs({
  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. Group targeting reads, merges and updates; campaign changes use PATCH with only the fields you change, which also works where an integration has no campaign retrieve (LinkedIn, as of 19 October 2026).

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 creation. As of 19 October 2026, campaign create is available on 21 of the 34 advertising integrations with a campaign object, including Google Ads, LinkedIn, Meta Ads, Microsoft Advertising, Reddit Ads, Snapchat and TikTok Ads; confirm it for the ad platforms your customers run before you ship. The create hierarchy is Campaign → Group → Ad, with a Creative created before the ad that references it for image or video ads (text ads need none), 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 address = job.addresses?.[0];
const countryCode = address?.countryCode; // e.g. 'US'

const targets = await sdk.ads.listAdsTargets({
  connectionId: adsConnectionId,
  orgId: organizationId, // ad account ID; required on 11 of the 22 integrations with a target lookup (as of 19 October 2026)
  type: 'countries',
  query: address?.country ?? countryCode ?? '',
  limit: 50,
});

// listAdsTargets returns an array; match on the job's country code
const country = targets.find((t) => t.id === countryCode);
if (!country) throw new Error('No targeting match for the job location');

// 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: {
      ...group.targeting,
      geographic: { countries: [{ id: country.id, name: country.name }] },
    },
  },
});

Targeting identifiers differ by integration, 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.

How do you change a campaign's budget and end date?

Send a PATCH with only the budget and schedule fields you change; there is no need to read the campaign first. Campaign updates also accept PUT.

// PATCH sends only the fields you change; the rest of the campaign is left as it is
await sdk.ads.patchAdsCampaign({
  connectionId: adsConnectionId,
  id: campaignId,
  adsCampaign: {
    budgetAmount: 5000,
    budgetPeriod: 'DAILY',
    endAt: new Date('2026-12-31T23:59:59Z'), // the SDK takes a Date and sends 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 → (creative, for media) → ad.

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
  connectionId: adsConnectionId,
  adsCampaign: {
    name: job.name,
    organizationId,
    status: 'PAUSED',
    budgetAmount: 5000,
    budgetPeriod: 'DAILY',      // DAILY / MONTHLY / TOTAL / LIFETIME
    currency: 'USD',            // writable on LinkedIn, Adform, Equativ and The Trade Desk only
    startAt: new Date('2026-11-01T00:00:00Z'),   // the SDK takes a Date and sends ISO-8601
    category: 'EMPLOYMENT',     // Meta only
  },
});

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
  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: country.id, name: country.name }], // resolved with listAdsTargets, as in Path A
      },
    },
  },
});

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 so candidates apply in-platform, and set optimization_goal to LEAD_GENERATION where the integration accepts it; optimization_goal is writable on Meta Ads, TikTok Ads, X Ads, OpenAI Ads and StackAdapt (as of 19 October 2026).

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
  connectionId: adsConnectionId,
  adsAd: {
    name: job.name,
    campaignId: campaign.id,
    groupId: group.id,
    headline: activePosting?.name ?? job.name,
    adCopy: activePosting?.description ?? job.description,
    cta: 'Apply',
    finalUrl: activePosting?.postingUrl ?? job.publicJobUrls?.[0],
    // creativeIds: [creative.id],                         // media ads: create the creative first (below)
    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 read-only on most integrations (writable on StackAdapt only, as of 19 October 2026), so the example doesn't set it.

To attach a promoted entity, look it up through the read-only Promoted endpoint and pass it in the promoted array as { id, type }, on the group for Meta Ads and Google Ads, or on the ad for X Ads and Amazon Advertising (as of 19 October 2026). 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 the creative first 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:

// Create the creative before the ad, then pass creativeIds: [creative.id] on the ad
const creative = await sdk.ads.createAdsCreative({   // POST /ads/{connection_id}/creative
  connectionId: adsConnectionId,
  adsCreative: {
    name: job.name,
    groupId: group.id,                                     // not writable on Meta
    creativeType: 'STANDARD',                              // writable on LinkedIn, Meta and Microsoft Advertising, among others
    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',
  },
});

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, put it in a raw object on the request body (adsCampaign.raw, adsGroup.raw, adsAd.raw); those fields go to the platform as written.

Which fields are writable, on which platforms?

Writability varies by integration, and the biggest variation is where the ad copy lives: some platforms keep it on the Ad, others on the Creative. On LinkedIn, Microsoft Advertising and Pinterest Ads (beta) 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 categoryno✓nonono
Ad headline / copy✓noheadlinecopy✓
Creative title / bodyno✓✓✓✓
Group optimization goalno✓no✓no
Table checked against Unified.to's per-integration field support on 19 October 2026; it changes as integrations change, so check each integration's Feature Support tab. As of 19 October 2026, 27 advertising integrations accept writes on at least one object; the rest are read-only.

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 treats employment ads as a Special Ad Category, alongside housing, financial products and services, and social issues, elections or politics (as of 21 October 2026). Google restricts targeting on employment ads in the United States and Canada (no targeting by sex, age, parental or marital status, or ZIP code, as of 21 October 2026). 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[]/skills for role context, but never map anything that resolves to a protected demographic characteristic. The unified model includes the full targeting surface; employment law, not the API, 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 on supported integrations (job update events on 71 ATS integrations as of 20 October 2026, including Greenhouse, Lever, Ashby and Workable; virtual on most of them; native or virtual on Ashby); 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.

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 sig256 validation.
// body.data is an array of ats_job records in the API's snake_case form.
for (const job of body.data) {
  const mapped = await findCampaignForJob(job.id); // your job → { adsConnectionId, campaignId, pausedByClose }
  if (!mapped) continue;

  if (job.status === 'CLOSED' || job.status === 'ARCHIVED') {
    // send status only; no campaign retrieve needed (LinkedIn has none)
    await sdk.ads.patchAdsCampaign({
      connectionId: mapped.adsConnectionId,
      id: mapped.campaignId,
      adsCampaign: { status: 'PAUSED' },
    });
    await markPausedByClose(job.id, true);
  } else if (job.status === 'OPEN' && mapped.pausedByClose) {
    // resume only campaigns this handler paused, not ones paused for review
    await sdk.ads.patchAdsCampaign({
      connectionId: mapped.adsConnectionId,
      id: mapped.campaignId,
      adsCampaign: { status: 'ACTIVE' },
    });
    await markPausedByClose(job.id, false);
  }
}

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. Where the ATS sends no webhooks, Unified.to's virtual webhooks check at an interval you set, as often as every minute on paid plans, so the campaign pauses within one interval of the role closing (half an interval on average), not the instant the recruiter clicks close. Virtual webhooks deliver created and updated events and suit cases where a short delay is acceptable. Virtual webhooks retry failed deliveries with progressive backoff for up to about two weeks (native webhooks retry three times, then stop), 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 varies by integration, and Unified.to shows the difference rather than hiding it. Campaign status writes (pause and resume) are supported across most advertising integrations (25 of 34 campaign integrations, as of 19 October 2026); 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 change events exist on a few integrations, mostly virtual (StackAdapt group events are native, as of 20 October 2026): campaign events on Meta Ads, Google Display & Video 360 and Snapchat, among others, and ad events on Meta Ads and TikTok Ads, among others. This flow doesn't depend on them: the trigger is the ATS job event, available on supported ATS integrations.
  • 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 by default (retries are opt-in through retryConfig).

A minimal handler for both:

import * as errors from '@unified-api/typescript-sdk/sdk/models/errors';

// 501: not supported on this integration, don't retry. 429: back off with jitter, then retry.
async function callWithBackoff<T>(call: () => Promise<T>, attempts = 5): Promise<T | undefined> {
  for (let i = 0; ; i++) {
    try {
      return await call();
    } catch (err) {
      if (err instanceof errors.UnifiedToError && err.statusCode === 501) return undefined;
      if (err instanceof errors.UnifiedToError && err.statusCode === 429 && i < attempts - 1) {
        const delay = Math.min(30_000, 1_000 * 2 ** i) * (0.5 + Math.random() / 2);
        await new Promise((resolve) => setTimeout(resolve, delay));
        continue;
      }
      throw err;
    }
  }
}

// e.g. await callWithBackoff(() => sdk.ads.patchAdsCampaign({ connectionId, id, adsCampaign: { status: 'PAUSED' } }));

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 standard AtsJob across 162 ATS integrations and a consistent campaign, group, and targeting model across 39 advertising integrations, reached through one connection model. Unified.to keeps no customer records at rest; connection credentials and operational metadata are encrypted at rest. You map the job to the campaign once; on supported integrations, the ATS job event keeps them aligned after that.

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 patchAdsCampaign, sending only the fields you change.
  6. Subscribe to the ats_job updated webhook and pause the campaign when status moves to closed.
  7. For job ads on Meta, set the campaign category to employment; elsewhere, meet the platform's employment-ad rules in its own setup. Keep targeting to broad geography, with no demographic targeting.
  8. Handle 501 (unsupported, do not retry) and 429 (rate limit, back off) explicitly.

Related: Build Cross-Platform Campaign Controls · Advertising API Integration · ATS API Integration

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

Frequently asked questions

Does Unified.to store the job or campaign data? No. Unified.to reads and writes the ATS and ad platforms at request time and keeps no customer records at rest; connection credentials and operational metadata are encrypted at rest.


Written by Mallory Greene.

About the author: Mallory Greene is Head of Marketing at Unified.to. She writes about integration infrastructure, unified APIs, and MCP for technical teams. Based in Toronto.

All articles