ScreenshotNeo

BlogHow-to

How to Automatically Generate LinkedIn Images for a Job Board

Build an approval-based workflow that turns job listings into branded LinkedIn images, validates assets, and publishes safely at scale.

By the ScreenshotNeo team29 September 202611 min read

How to Automatically Generate LinkedIn Images for a Job Board

Automatically generating a LinkedIn image for every job-board listing requires three separate stages: approved job data, image rendering, and publishing. Keep those stages independent so an unreviewed listing cannot become a public post and a rendering failure cannot create a broken LinkedIn update.

A dependable workflow looks like this:

  1. Store normalized listings in Airtable or another structured source.
  2. Move only reviewed records into an Approved view or status.
  3. Render each approved record with a reusable template and dynamic fields.
  4. Validate the image URL, dimensions, text, and accessibility data.
  5. Publish manually, schedule it, or send it through LinkedIn’s image-upload and post APIs.

The Bannerbear job-board workflow follows this pattern: an approved Airtable record triggers Zapier, which sends mapped fields to a Bannerbear template. The same design can be reused for every role while the title, logo, company, location, description, and application URL change per record. [Bannerbear’s tutorial](https://www.bannerbear.com/blog/automatically-generate-images-for-your-job-board/) is a useful reference, but connector labels and permissions should be checked against the current products before launch.

1. Design the data model before generating images

Image automation is only as reliable as the fields entering the template. Use one record per job and keep presentation fields separate from source fields where possible.

An approval gate separates job data from image generation and publishing.
An approval gate separates job data from image generation and publishing.
Field Purpose Validation
job_id Stable identifier for deduplication Required; never changes after creation
title Role name shown on the image Trim whitespace; define a maximum length
company_name Employer name Required for approved records
logo_url Company mark used by the template HTTPS image URL; verify it returns an image
location City, region, remote status, or hybrid detail Normalize spelling and separators
description Short summary for the creative Strip HTML; truncate to a known limit
application_url Destination for applicants HTTPS URL; preserve tracking parameters
approval_status Gate that permits generation Only an explicit Approved value triggers work
image_url Rendered asset returned by the image service Write after successful generation
published_at Publishing state Set only after LinkedIn confirms the post

Do not trigger from “record created” alone. A listing can be incomplete, duplicated, or still awaiting moderation. An Approved view, a single-select approval field, or an automation condition prevents premature generation. Keep a second idempotency field such as render_key = job_id + template_version; this lets you rerun a failed job without creating multiple images for the same approved listing.

2. Create a reusable branded template

Build one template with named dynamic layers instead of generating a new design for every role. Typical layers are a logo, title, company name, location, description, application URL, accent color, and background image.

Design for the longest realistic values. A two-line title may become four lines when a hiring manager enters a long specialization. Set a maximum title length, choose a fallback font, and reserve enough vertical space for wrapping. Keep important content inside a safe margin so LinkedIn previews do not crop it.

Use a consistent aspect ratio for organic posts. A square or landscape canvas is easier to reuse across social channels; choose one size, document it, and resize only at the final delivery step. Do not put critical information exclusively in the image: include the same role and company in the LinkedIn post text and provide useful alt text.

3. Normalize and approve records

Before calling an image renderer, run a validation step. Reject or route records to a review queue when:

  • The title, company name, or application URL is empty.
  • The logo URL is not HTTPS or does not return an image.
  • The description contains raw HTML, tracking scripts, or excessive length.
  • The approval value is anything other than the exact approved state.
  • The same job_id and template version already produced an image.

Normalize text with Unicode-aware trimming, collapse repeated whitespace, and convert line breaks to spaces before inserting values into a template. Preserve the original description in your database; store the normalized version separately so editors can audit what changed.

4. Connect the approved record to image generation

In an automation service, configure a trigger for a new record entering the Approved view. Map each Airtable field to the corresponding template layer. Save the renderer’s output URL, request ID, and template version back to the record.

Use a retry policy for transient failures, but make retries idempotent. A safe key is the job ID plus template version. If a retry sees an existing successful result for that key, it should reuse the asset rather than render another copy.

For teams that prefer to render locally, the following Node.js example creates a simple HTML card and captures it with Playwright. It is complete enough to run as a baseline; replace the CSS and fields with your brand system.

import { chromium } from 'playwright';
import fs from 'node:fs/promises';

const job = {
  title: 'Senior Data Engineer',
  company_name: 'Example Health',
  location: 'Remote — United States',
  description: 'Build reliable pipelines that help care teams make faster decisions.',
  application_url: 'https://example.com/jobs/senior-data-engineer'
};

const escapeHtml = (value) => String(value)
  .replaceAll('&', '&')
  .replaceAll('<', '&lt;')
  .replaceAll('>', '&gt;')
  .replaceAll('"', '&quot;')
  .replaceAll("'", '&#39;');

const html = `<!doctype html>
<html><head><style>
* { box-sizing: border-box; }
body { margin: 0; width: 1200px; height: 628px; font-family: Arial, sans-serif; background: #101828; color: white; }
.card { height: 100%; padding: 64px; display: flex; flex-direction: column; justify-content: space-between; }
.eyebrow { color: #98a2b3; font-size: 24px; }
h1 { max-width: 1000px; font-size: 62px; line-height: 1.05; margin: 22px 0; }
.meta { font-size: 30px; color: #d0d5dd; }
.description { max-width: 900px; font-size: 26px; line-height: 1.35; color: #eaecf0; }
.url { font-size: 20px; color: #84cae8; }
</style></head><body>
<main class="card">
  <div><div class="eyebrow">${escapeHtml(job.company_name)}</div>
  <h1>${escapeHtml(job.title)}</h1>
  <div class="meta">${escapeHtml(job.location)}</div></div>
  <div><p class="description">${escapeHtml(job.description)}</p>
  <div class="url">${escapeHtml(job.application_url)}</div></div>
</main></body></html>`;

await fs.writeFile('job-card.html', html);
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1200, height: 628 }, deviceScaleFactor: 2 });
await page.setContent(html, { waitUntil: 'networkidle' });
await page.screenshot({ path: 'job-card.png', fullPage: true, type: 'png' });
await browser.close();

Install and run it with npm install playwright and node generate-job-image.mjs. In production, load remote logos deliberately, set a timeout, and reject a record if required assets fail to load.

5. Validate the rendered asset

Do not mark a record complete merely because an API returned a URL. Fetch the URL and verify an image response, a nonzero content length, and the expected dimensions. Open a sample in a browser during setup, as the Bannerbear workflow recommends. Check long titles, missing logos, accented characters, right-to-left text, and unusually long locations.

Keep a human review option. A useful review table contains the job ID, source record URL, rendered image URL, template version, validation result, and reviewer decision. If a reviewer rejects an image, store the reason so the template or input data can be corrected without losing the original record.

6. Publish to LinkedIn safely

Rendering and publishing are different operations. You can schedule the image for manual sharing, use a social scheduling tool, or integrate directly with LinkedIn’s APIs. The LinkedIn Images API flow initializes an upload, uploads the binary, and references the resulting image URN in an image post. Requests use a Linkedin-Version header in YYYYMM format and X-Restli-Protocol-Version: 2.0.0. The API documentation lists JPG, GIF, and PNG support and a 36,152,320-pixel ceiling. Check the currently supported version and your application’s permissions before launch: Marketing Version 202510 is scheduled to sunset on October 15, 2026. [LinkedIn Images API documentation](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/images-api).

A simplified upload sequence in Python looks like this. The exact endpoint, OAuth scopes, owner URN, and payload fields depend on your LinkedIn application and current API version, so treat the placeholders as configuration that must be filled from the current documentation.

import os
import requests

TOKEN = os.environ['LINKEDIN_ACCESS_TOKEN']
OWNER_URN = os.environ['LINKEDIN_OWNER_URN']
VERSION = os.environ['LINKEDIN_VERSION']  # verify before deployment
headers = {
    'Authorization': f'Bearer {TOKEN}',
    'Linkedin-Version': VERSION,
    'X-Restli-Protocol-Version': '2.0.0',
    'Content-Type': 'application/json',
}

image = open('job-card.png', 'rb')
init = requests.post(
    'https://api.linkedin.com/rest/images?action=initializeUpload',
    headers=headers,
    json={'initializeUploadRequest': {'owner': OWNER_URN}},
    timeout=30,
)
init.raise_for_status()
data = init.json()['value']
upload_url = data['uploadUrl']
image_urn = data['image']

upload = requests.put(upload_url, headers={'Authorization': f'Bearer {TOKEN}'}, data=image, timeout=60)
upload.raise_for_status()

post = requests.post(
    'https://api.linkedin.com/rest/posts',
    headers=headers,
    json={
      'author': OWNER_URN,
      'commentary': 'Senior Data Engineer — apply here: https://example.com/jobs/senior-data-engineer',
      'content': {'media': {'id': image_urn, 'altText': 'Senior Data Engineer role at Example Health, remote in the United States'}},
      'distribution': {'feedDistribution': 'MAIN_FEED'},
      'lifecycleState': 'PUBLISHED',
      'visibility': 'PUBLIC'
    },
    timeout=30,
)
post.raise_for_status()
print(post.json())

Keep publishing credentials server-side. Record LinkedIn’s response ID and only set published_at after a successful response. If a request times out after LinkedIn accepted it, query your own idempotency record before retrying to avoid duplicate posts.

7. Respect the correct image specification

Use case Rules to check
Organic image post Use the current Images API documentation; verify supported formats, dimensions, permissions, and version headers.
Carousel sponsored content Reviewed specifications allow JPG, PNG, or non-animated GIF, up to 10 MB and 4320 × 4320 pixels; 1080 × 1080 at 1:1 is recommended. Headlines can truncate after two lines. [LinkedIn carousel specifications](https://business.linkedin.com/marketing-solutions/success/ads-guide/ads-guide-carousel).
Dynamic job ads These are paid formats based on member-job matching. They are not required for ordinary job-board image posts. [LinkedIn dynamic job ads](https://www.linkedin.com/business/marketing/blog/linkedin-ads/introducing-dynamic-job-ads).

8. Or skip the browser setup

ScreenshotNeo can capture the rendered job page or a hosted HTML template with one request. See the ScreenshotNeo documentation for all options.

Consent banners and overlay widgets can be cleared before the screenshot is captured.
Consent banners and overlay widgets can be cleared before the screenshot is captured.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

For a job-board workflow, pass the URL of a listing-specific template page and choose options such as full-page capture, an element selector, custom CSS, a wait condition, a delay, network-idle waiting, a device preset, retina scale, or a transparent background. You can also hide selectors, click an element, block ads and trackers, set cookies or headers, choose a timezone or geolocation, resize the output, cache with a TTL, create signed links, submit async jobs with signed webhooks, or capture up to 100 URLs in one bulk call.

  • Cookie banners, newsletter popups, and chat widgets are removed before the shot; each cleanup step can be turned off.
  • Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed. Inspect X-Page-Verdict and X-Billed on every response.
  • An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.
  • Every plan includes all features. The free plan includes 1,000 screenshots per month without a card; paid plans start at $5 for 3,000 shots.

Create a free ScreenshotNeo account and use the 1,000 monthly screenshots to validate your template and edge cases before scaling.

9. Reliability, performance, and cost controls

Reliability checklist

  • Use a queue between approval and rendering so traffic spikes do not drop records.
  • Persist request IDs, template versions, response status, and error messages.
  • Retry only transient network and server errors with exponential backoff.
  • Dead-letter records after a fixed retry count for human review.
  • Keep source records immutable enough to reproduce an image later.
  • Monitor duplicate renders and duplicate LinkedIn posts separately.

Performance

Render only after approval, cache identical template-and-data combinations, and avoid loading unnecessary third-party resources. For large batches, process concurrently within the limits of your renderer and LinkedIn application. Full-page captures and remote logos take longer than fixed viewport captures; set explicit timeouts and wait conditions instead of sleeping for an arbitrary long period.

Cost

Track cost per approved listing, not per incoming record. Deduplication prevents paying for repeated renders. With ScreenshotNeo, clean shots are billed while bot checks, blank pages, timeouts, failed loads, and cache hits are free; response headers identify the verdict and billing status. Plans are Free: 1,000/month, Starter: $5 for 3,000, Growth: $15 for 15,000, Pro: $39 for 60,000, Scale: $99 for 250,000, and Business: $249 for 1,000,000. Yearly billing provides two months free.

10. Troubleshooting common failures

Symptom Likely cause Fix
Image generated for an unapproved job Trigger watches all new records Trigger on the Approved view or require an exact approval value.
Logo is missing Private, expired, or non-image URL Use a public HTTPS asset, validate content type, and provide a fallback logo.
Text is clipped Long title or description exceeds template bounds Set character limits, wrap deliberately, and test worst-case fixtures.
Blank screenshot Page has not finished rendering or requires blocked resources Wait for a selector or network idle, then inspect the page verdict and logs.
Cookie notice covers content Consent UI appears before capture Accept or remove the banner in your browser script, or enable ScreenshotNeo’s consent cleanup.
LinkedIn rejects upload Wrong version header, format, size, or permission Check the current Images API requirements, pixel count, OAuth scopes, and application access.
Duplicate LinkedIn posts Retry occurred after an unknown timeout Store a client idempotency key and reconcile the response before retrying.
Automation loops forever Writing the rendered URL updates the same trigger view Trigger only on approval transitions and ignore records with a completed render key.

11. FAQ

Should every job listing become a post?

No. Require approval, deduplicate by job ID, and skip listings that lack complete or publishable data.

Can I use the same image for organic posts and ads?

Sometimes, but validate the specification for the exact placement. Carousel ad limits and organic image-post requirements are different.

Do I need LinkedIn’s API to share images?

No. Manual sharing or a scheduler can publish the rendered asset. Direct API publishing requires the appropriate LinkedIn application access and permissions.

How should alt text be generated?

Describe the role, company, and location in plain language. Do not repeat a long URL or rely on text that exists only inside the image.

When should I regenerate an image?

Regenerate when approved source fields or the template version changes. Keep the previous asset and its metadata for auditability.

Can an AI agent operate this workflow?

Yes. ScreenshotNeo’s MCP server exposes screenshot, page-information, and PDF-capture tools to MCP clients such as Claude and Cursor. Keep approval and publishing permissions explicit.