Adds a social composer to the Payload admin, both per record and as a standalone page, with real API adapters for Pinterest, Instagram Business, Facebook Pages and Google Business Profile. It registers post and template collections plus an encrypted connections global, and defines Payload Jobs tasks for scheduling, retries and insights. The host supplies source resolvers, authorization, credentials and an encryption key, and needs Payload 3.84.1 or newer and Node 22.12 or newer.
Multi-platform social publishing plugin for Payload CMS — Pinterest, Instagram Business, Facebook Pages, Google Business Profile
Installation
npm i payload-plugin-socials3
Stars
0
Forks
3
Open Issues
Today
Last Updated
24
Weekly Downloads
v0.11.0
npm Version
1.0 MB
Unpacked Size
0
Dependencies
Trends appear after 7 days of data. Collecting since Oct 22 — 2 daily snapshots so far.
Trends appear after 7 days of data. Collecting since Oct 22 — 2 daily snapshots so far.
Each point is npm's rolling 7-day download count.
Health Score
Composite score based on GitHub activity, stars, npm downloads, publish recency, dependency count, and package size.
Badge for your README
[](https://payloaddirectory.dev/plugins/1298589351-root)<a href="https://payloaddirectory.dev/plugins/1298589351-root"><img src="https://payloaddirectory.dev/badge/1298589351-root.svg" alt="Payload Directory"></a>README
Multi-platform organic social publishing for Payload CMS 3.
Compose, preview, template, schedule, publish, reconcile, and measure posts across Pinterest, Instagram Business, Facebook Pages, and Google Business Profile. The plugin provides real API adapters, encrypted credential storage, idempotency controls, a durable audit trail, and Payload-native admin surfaces. It does not provide a copy-and-paste fallback. Its editorial defaults can optionally use the documented commerce-style metadata convention, while transformations, copyOverrides, and templates let every host replace that copy without embedding application-specific catalog logic.
Running in production at Fine's Gallery. This plugin was built for, and extracted from, Fine's Gallery, a luxury bronze and marble retailer with nearly 1M annual users, where it is the social publishing control center.
The composer, end to end. Publishing a live product post directly from the Payload admin: open the record's Socials tab, pick the media, review the generated per-platform copy in each channel tab, preview, and publish. The post is live on the real platform seconds later, with the publication recorded in the plugin's audit collection.
Fully agent-drivable. Every capability in that composer is also a deterministic, authenticated HTTP surface (discovery, validation, multi-channel publish, history, deletion, and reconciliation), so an AI agent can run your social publishing without the plugin embedding any model. Here, an agent composes and publishes a post through the same API a human would use, with idempotent retries and a durable audit trail guarding every write. See the agent and headless integration guide.
/admin/socials.social-posts and social-templates collections.socials-connections global with AES-256-GCM encrypted tokens and key rotation.The host supplies source resolvers, authorization, credentials, the encryption key, and any scheduling trigger.
^3.84.1>=22.12.0^19.0.0 for Payload admin surfacessharp only when assets.autoConvert is enabledpnpm add payload-plugin-socials
| Platform | Implementation | Release guidance |
|---|---|---|
| OAuth, board discovery/CRUD, image and video Pins, insights, delete | Live in production at Fine's Gallery; run a controlled canary with your own credentials | |
| Facebook Pages | System-user or OAuth credentials; link, photo, multi-photo, video, native scheduling, insights, delete | Live in production at Fine's Gallery; Meta app must be Live for public visibility, then verify with a non-role account |
| Instagram Business | System-user or OAuth credentials; image, Reels, Stories, Carousel, insights | Live in production at Fine's Gallery; Meta app must be Live; remove upstream media in-app and reconcile locally |
| Google Business Profile | OAuth or service account; account/location discovery; Standard, Event, Offer, provider-permitted Alert; insights, delete | Experimental: API-complete and contract-tested, but requires Google Basic API Access and has not yet run a live canary |
Provider approval, quotas, and policy compliance remain the host's responsibility. See the platform guides before enabling an adapter.
Meta Development mode is role-only. Facebook or Instagram publishing can return a successful object ID while the test content is visible only to app-role users such as administrators, developers, and testers. Public publishing requires the app/use case to be Live with the required permission access. Remove unwanted test posts before switching modes, then validate a canary from a logged-out or non-role account. See Meta App Modes and the installation guide.
This example maps an illustrative content collection to an entry source
kind. Replace the collection, fields, and source-kind string with concepts from
your own schema; source kinds are host-defined.
import { buildConfig } from 'payload'
import { payloadPluginSocials } from 'payload-plugin-socials'
export default buildConfig({
plugins: [
payloadPluginSocials({
encryptionKey: process.env.SOCIALS_ENCRYPTION_KEY!,
authorize: async ({ req, scope }) => {
const role = req.user?.role
if (!role) return false
return scope === 'admin'
? ['admin', 'owner'].includes(role)
: ['admin', 'owner', 'editor'].includes(role)
},
sources: {
entry: async ({ payload, id }) => {
const entry = await payload.findByID({ collection: 'content', id, depth: 1 })
return {
sourceId: String(entry.id),
sourceKind: 'entry',
baseTitle: entry.title,
baseDescription: entry.summary ?? '',
link: `${process.env.PUBLIC_URL}/content/${entry.slug}`,
images: [entry.hero, ...(entry.media ?? [])].flatMap((asset, index) =>
typeof asset === 'object' && asset?.url
? [{ role: index === 0 ? 'main' : 'detail', url: asset.url }]
: [],
),
metadata: { audience: entry.audience, tags: entry.tags },
}
},
},
admin: {
injectInto: [{ collection: 'content', sourceKind: 'entry' }],
mode: 'both',
},
platforms: {
pinterest: {
clientId: process.env.PINTEREST_APP_ID!,
clientSecret: process.env.PINTEREST_APP_SECRET!,
enabled: true,
redirectUri: `${process.env.PUBLIC_URL}/api/socials/pinterest/oauth/callback`,
},
},
}),
],
})
After adding or upgrading the plugin, generate the host-owned artifacts and inspect the migration before applying it:
pnpm payload generate:types
pnpm payload generate:importmap
pnpm payload migrate:create socials
pnpm payload migrate
The plugin never creates or runs migration files.
Required options:
| Option | Purpose |
|---|---|
encryptionKey | A 32-byte Buffer, 64-character hex string, or valid base64 key used for credential encryption |
authorize | Host authorization for publisher and admin scopes |
sources | At least one source-kind-to-resolver mapping |
platforms | At least one declared platform; each adapter also has an enabled flag |
payloadPluginSocials({
encryptionKey,
authorize,
sources,
platforms: {
pinterest?: {
enabled: boolean
clientId?: string
clientSecret?: string
redirectUri?: string
environment?: 'production' | 'sandbox'
scopes?: string[] // defaults to the plugin's required scope set
allowedVideoHosts?: string[] // required to publish video Pins
videoUploadTimeoutMs?: number
}
facebook?: {
enabled: boolean
appId?: string
appSecret?: string
redirectUri?: string
systemUserAccessToken?: string
apiVersion?: string // e.g. 'v25.0'
scopes?: string[]
}
instagram?: {
enabled: boolean
appId?: string
appSecret?: string
redirectUri?: string
systemUserAccessToken?: string
apiVersion?: string // e.g. 'v25.0'
scopes?: string[]
}
gbp?: {
enabled: boolean
clientId?: string
clientSecret?: string
redirectUri?: string
serviceAccount?: { clientEmail: string; privateKey: string; impersonate?: string }
}
}
authCollection?: string // auto-detected only when the host has exactly one auth collection
admin?: {
mode?: 'route' | 'dashboard' | 'both' | 'headless'
route?: `/${string}`
navLabel?: string
injectInto?: Array<{
collection: string
sourceKind: string
label?: string
tabPosition?: 'append' | 'before-last' | number
}>
mediaLibrary?: {
collectionSlugs?: string[]
filterOptions?: Partial<Record<string, Where | boolean>>
}
}
api?: { basePath?: `/${string}` }
transformations?: SocialsTransformations
copyOverrides?: SocialsCopyOverrides
assets?: {
preflight?: boolean
autoConvert?: boolean
renditionCollection?: string
renditionData?: Record<string, unknown>
normalize?: boolean
jpegQuality?: number
maxSourceImageBytes?: number
maxConcurrentConversions?: number
remoteFetchTimeoutMs?: number
trustedRemoteHosts?: string[]
}
scheduling?: {
internalApiKey?: string
queue?: string
autoRun?: {
cron?: string
insightsRefreshCron?: string
connectionRefreshCron?: string
}
sweepBatchSize?: number
drainBatchSize?: number
connectionRefreshWindowSeconds?: number
insightsRefreshWindowSeconds?: number
}
rateLimit?: SocialsRateLimitConfig
coordination?: SocialsCoordinationConfig
webhooks?: SocialsWebhookConfig
previousEncryptionKeys?: readonly (string | Buffer)[]
testMode?: boolean
disabled?: boolean
})
admin.mediaLibrary scopes the "Add from media library" drawer in both composers to specific upload collections and, through serializable per-collection Where filters, to specific documents (for example, public images only). Without it the drawer lists every upload collection.
If a host has multiple auth-enabled collections, authCollection is required so the optional publishedBy relationship has an unambiguous target. A host with no auth collection may omit it; request authorization still remains mandatory.
The plugin supports one payloadPluginSocials(...) instance per Payload config and throws during config construction if a second instance is applied. Combine all enabled platforms and source resolvers in that instance.
With disabled: true, the plugin keeps its two collections and connection global in the schema, but hides them, denies access, removes hooks, and registers no endpoints, tasks, or admin surfaces. Keep the same sources keys and authCollection as enabled mode so migrations remain stable; encryption and platform credentials may be omitted.
A resolver converts a host document into the intentionally small, channel-neutral SocialSourceContext:
type SourceContextResolver = (args: {
id: number | string
payload: Payload
req?: PayloadRequest
}) => Promise<SocialSourceContext>
type SocialSourceContext = {
sourceId: string
sourceKind: string
baseTitle: string
baseDescription: string
link: string
images: Array<{
url: string
role: string
width?: number
height?: number
format?: 'avif' | 'bmp' | 'gif' | 'jpeg' | 'png' | 'tiff' | 'webp'
alt?: string
}>
videos?: Array<{ url: string; coverImageUrl?: string }>
modelLabel?: string | null
metadata?: Record<string, unknown>
// Optional per-platform hints the default copy builders read.
hints?: { pinterestSegments?: string[]; instagramHashtags?: string[] }
}
Use a stable string sourceId, keep domain fields in metadata, and return durable public HTTPS media URLs. Provider fetchers cannot use localhost, private networks, or short-lived signed URLs. Exact metadata.* paths are available to templates.
Copy resolves in this order:
'*' config transformation.copyOverrides.Transform strings use the safe {{ path | filter }} renderer. Supported filters are upper, lower, trim, truncate:N, and default:text.
transformations: {
entry: {
pinterestTitle: '{{ title }} | {{ metadata.audience | default:Featured }}',
instagramCaption: '{{ title }}\n\n{{ description | truncate:2000 }}',
},
'*': { facebookMessage: '{{ title }}\n\n{{ description | truncate:400 }}' },
}
The default identity is derived from the platform, source, destination, format where relevant, and primary asset. Copy edits change the audit sourceHash, not the default identity. A previously published identity returns its existing reference.
Use publicationKey in a platform input when the caller owns a stable logical campaign identity. Use idempotencyKeyOverride only to adopt a known legacy 64-character SHA-256 key during migration. Reusing either value intentionally addresses the same audit row.
The plugin retries safe reads and explicitly retryable throttling responses. It does not blindly replay an unsafe provider POST when its typed transport result cannot rule out provider acceptance. A provider success followed by local audit-persistence failure is fenced the same way, with the returned provider ID captured on the row whenever the database is reachable. The row becomes unknown (or remains non-reclaimable as publishing if the fence write also fails) and automatic publication retry is suppressed until an operator verifies the provider and calls:
POST /api/socials/posts/{postId}/resolve
Content-Type: application/json
{ "outcome": "published", "externalId": "provider-id", "externalUrl": "https://..." }
or { "outcome": "not-published" }. This prevents retry logic from creating duplicate public posts when the provider accepted a request but its response was lost.
The plugin defines the jobs and their tasks; the host owns the trigger and runs the workers (the same contract as the sibling Merchant Center plugin). The plugin never writes to your jobs.autoRun. Choose any of these, and mix freely:
scheduling.autoRun crons and the plugin attaches Payload task-level schedule entries to its sweep/insights/connection tasks. Run a Payload Jobs worker that handles schedules and drains the queue: configure your own jobs.autoRun in the Payload config, or call payload.jobs.handleSchedules() + payload.jobs.run() on a cron.POST /api/socials/internal/jobs/sweep (and /insights/refresh, /connections/refresh) with the X-Socials-API-Key. No Payload worker required. This is what Fine's Gallery runs in production.sweepDueSocialPosts(payload, normalizedOptions) and runQueuedSocialsJobs(payload, normalizedOptions) in a loop.Scheduled publishes (scheduledAt on a source publish) are enqueued with waitUntil immediately and drained by any of the above. The periodic insights/connection/sweep tasks fire under the native mode (via task schedule) or when you call the internal endpoints / helpers.
The queue name is independent of cron configuration. If you select a custom queue, use it in both places:
export default buildConfig({
jobs: {
// Host-owned worker cadence. This drains the queue and handles task schedules.
autoRun: [{ cron: '* * * * *', queue: 'operator-socials' }],
},
plugins: [
payloadPluginSocials({
// ...credentials, platforms, sources, authorize...
scheduling: {
queue: 'operator-socials',
autoRun: {
cron: '*/5 * * * *',
insightsRefreshCron: '0 * * * *',
connectionRefreshCron: '0 3 * * *',
},
},
}),
],
})
Scheduling uses a per-revision token, rechecks the due time in the task, and cancels superseded Payload jobs when possible. The token and status checks remain the correctness fence if cancellation races with a worker.
Facebook's scheduledPublishTime is provider-native scheduling. The generic source-publish scheduledAt is plugin scheduling. Accepted GBP posts and native Facebook schedules remain pending until reconciliation confirms the provider state; sweep jobs provide the backstop.
With preflight enabled, remote image inspection is HTTPS-only, redirect-bounded, time-bounded, and streamed under a processing cap (assets.maxSourceImageBytes, 25 MiB by default). Every redirect hostname is resolved and validated, and the HTTPS connection is pinned to that validated address while preserving the hostname for TLS SNI and certificate verification. DNS names resolving to local, private, link-local, documentation, or otherwise non-public IP ranges are rejected. trustedRemoteHosts is an explicit exact-host bypass for a host-controlled private media origin and should normally remain empty; those connections are still DNS-pinned.
Image detection uses file signatures, not URL extensions, so extensionless CDN URLs are supported. With preflight enabled, an unreadable or unrecognized signature fails closed instead of passing an uninspected asset to a provider. Set assets.preflight: false only when the host deliberately accepts that pass-through behavior.
When autoConvert is enabled, configure a dedicated public upload collection with no formatOptions; the plugin writes deterministic JPEG renditions there. Idempotency continues to use the original media URL. assets.renditionData supplies field values the rendition collection requires, such as alt text.
assets.normalize extends conversion to every image, including formats the platform already accepts. Each rendition applies EXIF orientation, flattens transparency to white, converts to sRGB, strips metadata and is fitted onto a per-platform canvas without cropping: Facebook keeps the photograph's own proportions within 2048 pixels and is never enlarged, Instagram stories use a 1080 by 1920 canvas, and every other image is padded onto a 1080 pixel square so a carousel of mixed ratios publishes intact. Sources are decoded within a 64 megapixel budget and the encoded result must fit the platform's byte limit. Renditions are cached per source URL, platform, presentation and quality. Raise maxSourceImageBytes when originals are large; maxConcurrentConversions (default 1) bounds how many conversions a process runs at once, which matters for multi-photo and carousel publishes.
Publishing can outlive an HTTP request when a host runs provider calls on a worker. The composers send X-Socials-Publish-Async: 1 with every publish request. A host that defers the request may answer 202 Accepted with X-Socials-Publish-Status: pending, a Location header pointing at {basePath}/requests/{id} (same origin, numeric id, no query string) and an optional Retry-After in seconds. The composer keeps the publish control busy and polls that URL with GET until a non-pending response arrives, then handles it exactly as it would the original publish response, including provider errors. The publish request is submitted once; polling only reads, is clamped to one to five seconds between attempts and gives up after ten minutes with a message that points the operator at post history. Hosts that answer synchronously need no changes, and an unrelated 202 without the status header is passed through untouched. The header names and path segment are exported as SOCIAL_PUBLISH_ASYNC_HEADER, SOCIAL_PUBLISH_STATUS_HEADER, SOCIAL_PUBLISH_STATUS_PENDING and SOCIAL_PUBLISH_REQUESTS_SEGMENT.
Every API and admin surface calls the host's authorize function. publisher covers compose, preview, validation, publish, history, cancellation, delete, reconciliation and the Business Profile listing read. admin covers connection and template administration and the Business Profile listing writes (profile edits, open status, deletion), which are mounted only with platforms.gbp.listingManagement: true.
Stored tokens use authenticated AES-256-GCM envelopes. Keep encryptionKey stable and identical across all instances. During rotation, make the new key primary and temporarily provide old keys in previousEncryptionKeys; successfully read envelopes are rewritten with the primary key. Secret-bearing fields are redacted before plugin logs reach the host logger.
For multi-process deployments, provide coordination.withLock for credential rotation and coordination.acquireRequestLease for fleet-wide outbound quotas. The built-in limiter is process-local.
Paths below are relative to Payload's /api route and the default /socials plugin base.
| Method | Path | Access |
|---|---|---|
| GET | /socials/health | Public, non-secret status |
| GET | /socials/access | Capability probe |
| GET | /socials/connections | Admin |
| POST / GET | /socials/{platform}/oauth/start, /oauth/callback | Admin / OAuth state |
| POST | /socials/{platform}/disconnect | Admin |
| GET / POST | /socials/{platform}/{kind}/{id}/preview, /validate | Publisher |
| POST | /socials/{platform}/{kind}/{id}/publish | Publisher |
| GET | /socials/{platform}/{kind}/{id}/history | Publisher |
| POST | /socials/compose/publish, /compose/validate | Publisher |
| DELETE | /socials/posts/{postId} | Publisher |
| POST | /socials/posts/{postId}/cancel, /resolve | Publisher |
| GET | /socials/pinterest/boards, /facebook/pages, /instagram/accounts, /gbp/locations | Publisher |
| GET | /socials/gbp/locations/{locationId} | Publisher |
| PATCH | /socials/gbp/locations/{locationId} (with listingManagement) | Admin |
| POST | /socials/gbp/locations/{locationId}/open-status (with listingManagement) | Admin |
| DELETE | /socials/gbp/locations/{locationId} (with listingManagement) | Admin |
| POST | /socials/templates/preview | Admin |
| POST | /socials/internal/jobs/sweep, /jobs/run | Scheduler API key |
The source preview route is GET for UI convenience; validate and all mutations are POST. The internal API key is separate from Payload user authentication.
HTTP is the stable integration surface for headless services. The low-level server helper is also exported when host code already has the normalized options and source context:
const result = await composeAndPublish({
ctx,
options: normalizedOptions,
payload,
req,
targets: [
{
platform: 'pinterest',
input: { boardId: 'board-id', imageUrl: ctx.images[0]?.url },
},
],
})
normalizePluginOptions is exported for worker entrypoints that construct the same host configuration outside Payload's plugin pipeline.
The Business Profile listing helpers (getLocation, updateLocation, setOpenStatus, deleteLocation, listAllLocations) and cancelQueuedSocialPost are exported the same way, for hosts that run provider work in their own job runner. See Google Business Profile.
Schema changes are versioned and documented in CHANGELOG.md. The host always generates, reviews, and applies its own Payload migration. Before production, run the repository's complete release gate and controlled canaries against each enabled provider account; automated tests never create or delete live social content.
MIT
payload instagram plugin
Connect an Instagram feed and import selected posts into a Payload collection
4321/wk25v3
payload dub
Create and sync Dub shortlinks, tags and analytics for published Payload documents
78235/wk4v4
payload plugin deskcrew
Turn new documents in chosen collections into DeskCrew support tickets
654/wk0v2
payload plugin unirate
Server-side proxy for the UniRate currency exchange and VAT API with a prefilled currency select field
593/wk0v3
payload discord sync
Sync Discord guilds, channels, members and roles into Payload collections using discord.js
473/wk0v3
payload reserve
Reservation and booking system with conflict detection, status workflows and a calendar admin view
79518/wk39v3