n8n social media scheduling: Sheets to queue
Developer workflow: approved spreadsheet rows to calendar receipts
n8n social media scheduling should finish with a saved post ID and a verified calendar record, not merely a green workflow run. Keep approval in Google Sheets, send an immutable scheduling request to PostSider, and write the receipt back to the same row. This recipe is source-checked, with offline request-mapping checks; it has not been executed in n8n or against a live publishing account.
In short
- Save the exact request and its stable key before sending.
- Write the returned post ID before attempting detailed readback.
- Reconcile unresolved rows without turning a failed GET into another create.
Download the manual, verified n8n → PostSider draft workflow
For a smaller starting point than a Google Sheets queue, download the verified-agent-drafts workflow JSON. It demonstrates one manual n8n run from an explicit test channel ID to a saved PostSider draft and a server read-back. It does not schedule or publish anything.
Use n8n’s Import from File action, then:
- In PostSider Settings > API, create an organization API key. In current PostSider builds, named keys have selectable scopes: choose
channels:read,posts:readandposts:writefor this template. New keys default to read-only, so select the write scope explicitly.posts:writealso permits scheduling and immediate publication; there is no draft-only grant. Legacy credentials may have broader access. If your dashboard has no scope selector, verify your deployed version before proceeding. In n8n, store the key as a Header Auth credential, nameAuthorization, value the raw key withoutBearer. Never paste it into JSON or a Code node. - Choose that same credential in the three HTTP Request nodes (
02 List connected channels,04 Create ONE draft,06 Read saved post). - Edit only the
01 Safe demo settingsCode node: enter one real connected test channel ID, human-reviewed text, a future UTC timestamp and one persistent idempotency key. Keep these unchanged if you retry. - Execute manually. The workflow first checks that the channel is active, creates
type=draft, validates one{postId, integration}receipt, fetches the exact post and refuses to report success unless the stored state isDRAFT, with matching content and channel.
This JSON is deliberately unconfigured (no live credential, channel ID or fixed production date) so importing it cannot accidentally publish. The file’s structure and safety gates are checked in CI, but you must run it with your own test channel and credential before treating it as a real end-to-end result. A green n8n execution does not establish a later social-network publication.
Your sheet needs a receipt column, not just a caption
Every approved revision needs a unique row identity and a durable receipt. The sheet remains your content source; the receipt connects that source to the job a teammate can inspect in the dashboard.
Use this illustrative column contract. Empty cells below are intentionally empty, not credentials or default API values.
| Column | Example or initial value |
|---|---|
record_id | campaign042 |
revision | 1 |
approval_status | approved |
channel_id | YOUR_VERIFIED_X_CHANNEL_ID |
content | Your approved announcement. |
publish_at_utc | REPLACE_WITH_FUTURE_UTC_ISO_TIMESTAMP |
request_json | Empty until prepared |
idempotency_key | Empty until prepared |
post_id | Empty until accepted |
sync_state | ready |
last_error | Empty |
Keep record_id unique across this worksheet and retain the same row during recovery. For another reviewed revision, archive the old receipt separately before resetting its operation fields. Never edit a prepared operation in place.
ready, prepared, pending_verification, scheduled_verified, error, and published_verified are your workflow labels, not PostSider API enums. Credentials belong in n8n credentials, never spreadsheet cells. Restrict edits to operation columns so a caption change cannot silently replace a saved request.
Read one approved row before enabling the timer
Build the manual path for one selected record_id first. Recovery must branch before preparation, because rebuilding an unresolved request changes the operation you are trying to recover.
Name nodes in this order: Manual Trigger, Read Row, Route Row, Prepare Request, Save Prepared, Load Prepared, Create Post, Save Receipt, Read Post, Check Receipt, Mark Scheduled.
In Read Row, use Google Sheets Get Row(s), choose the document and worksheet, and filter record_id to campaign042. Require exactly one result. In Route Row, use If nodes: a nonempty post_id goes directly to reconciliation; an approved prepared row goes to Load Prepared; only an approved ready row with empty request, key and ID reaches preparation. Everything else stops for review.
All write nodes use Update Row, manual column mapping, and Column to Match On: record_id. Match using the saved row identity, not its physical row number. The official Sheets operations document these operations, but do not establish atomic locking.
For this starter, allow only one row per execution. Enforce a single worker with no overlapping manual or scheduled executions, or use an external atomic claim before production. Processing rows serially within a run does not prevent another run from selecting the same row.
Prepare the immutable request before making the HTTP call
Generate the request once, then read its persisted version before sending. A successful preparation write is a prerequisite, not a side branch that can fail while publishing continues.
Use a Code node named Prepare Request, set to Run Once for Each Item:
const r = $json;
if (!/^[A-Za-z0-9_-]+$/.test(r.record_id) ||
!Number.isInteger(Number(r.revision)) || Number(r.revision) < 1)
throw new Error('Invalid operation identity');
if (!r.channel_id || !r.content || !/Z$/.test(r.publish_at_utc))
throw new Error('Missing content, channel or UTC date');
const date = new Date(r.publish_at_utc).toISOString();
if (Date.parse(date) <= Date.now()) throw new Error('Use a future date');
const body = {
type: 'schedule', date, shortLink: false, tags: [],
posts: [{
integration: { id: r.channel_id },
value: [{ content: r.content, image: [] }],
settings: { __type: 'x', who_can_reply_post: 'everyone' }
}]
};
return { json: { ...r, request_json: JSON.stringify(body),
idempotency_key: `sheet-${r.record_id}-r${r.revision}`,
sync_state: 'prepared' } };
In Save Prepared, map record_id, request_json, idempotency_key, and sync_state from their corresponding {{ $json.FIELD }} expressions. Stop on write errors. Load Prepared reads that exact record_id again and checks its request and key against Prepare Request before continuing. Recovery entries instead check that both saved fields exist and parse correctly; they must never rerun preparation.
The sample date is illustrative. Replace it with a future UTC appointment before execution. Reserve the sheet- key namespace for this worksheet within the organization. The backend hashes JSON.stringify(body) for idempotency, so preserve property order as well as values. Offline checks confirmed stable JSON roundtripping and the sample row mapping, not provider acceptance.
Configure one HTTP request for one known account
Use the standard HTTP Request node with generic Header Auth. This recipe does not depend on a specialized PostSider node or a downloadable workflow.
First call GET https://api.postsider.com/public/v1/integrations and explicitly identify the intended X account. Read /integration-settings/:id for that account before using the X-only settings above; changing just the ID does not make this payload suitable for another network.
Following the HTTP Request documentation, configure Create Post:
- Method: POST; URL:
https://api.postsider.com/public/v1/posts. - Authentication: Generic Credential Type, Header Auth. Credential header:
Authorization; value: raw organization key, withoutBearer. - Send Headers:
Idempotency-Key={{ $('Load Prepared').first().json.idempotency_key }}. - Send Body: JSON, Using JSON =
{{ JSON.parse($('Load Prepared').first().json.request_json) }}. - Response: JSON, with Include Response Headers and Status enabled; leave Never Error disabled.
Keep keys out of exports and logs. See the complete API scheduling request for authentication and request details. The spreadsheet column names are your contract; they are not API field names.
Write the post ID before calling the row scheduled
Create returns array entries with postId and integration, not complete posts. Persist that ID immediately, then verify what the scheduler stored.
Before Save Receipt, an If node requires a 2xx status, an array in $json.body, exactly one entry, a nonempty body[0].postId, and body[0].integration matching the saved request destination. Stop on any unexpected shape. With the response configuration above, map:
| Update Row field | n8n expression or literal |
|---|---|
record_id | {{ $('Load Prepared').first().json.record_id }} |
post_id | {{ $json.body[0].postId }} |
sync_state | pending_verification |
last_error | Empty string |
Read Post uses the same credential and GET URL {{ 'https://api.postsider.com/public/v1/posts/' + encodeURIComponent($('Create Post').first().json.body[0].postId) }}. Return JSON body only here. The posts reference describes creation and readback; source inspection confirms detail has a top-level integration ID and a posts array.
Set the Check Receipt If boolean expression to:
{{ (() => {
const r = $('Load Prepared').first().json;
const expected = JSON.parse(r.request_json);
const id = $('Create Post').first().json.body[0].postId;
const post = $json.posts?.find(p => p.id === id);
return $json.integration === expected.posts[0].integration.id &&
post?.content === expected.posts[0].value[0].content &&
Date.parse(post?.publishDate) === Date.parse(expected.date) &&
post?.state === 'QUEUE';
})() }}
Only true updates the same record_id to scheduled_verified. Otherwise retain the receipt and record the mismatch in last_error.
Recover the same operation instead of starting another
Reconciliation reads existing jobs; it does not create replacements. A saved ID must always bypass Create Post, including when a previous GET failed.
Use Read Pending to select one pending_verification row. GET /posts/ plus {{ $('Read Pending').first().json.post_id }}. Reuse the comparison, replacing r with $('Read Pending').first().json and id with r.post_id. Update by r.record_id; retain the unresolved state on failure.
| Failure boundary | Recovery |
|---|---|
| POST timeout or 5xx | Bounded retry with unchanged saved request and key. |
| POST accepted, receipt write failed | Replay saved request/key to recover the ID, then repair writeback. |
| ID saved, GET failed | Retry GET only; never recreate. |
| 400 | Inspect validation; deliberately approve and prepare a corrected revision. |
| 401 | Fix the credential before retrying. |
| 409 | Inspect invalid key, changed-body conflict or still-processing message; do not invent another key. |
| 429 | Wait according to server guidance, then retry the same operation. |
A changed caption or appointment is a reviewed revision, not a retry. Reconcile any existing post before scheduling its replacement. Read how social posting idempotency works for the underlying pattern. Neither idempotency nor a Sheets update promises exactly-once publication.
Scheduled and published need separate verification
scheduled_verified means the stored job matched at readback time. After its due time, GET it again and require PUBLISHED before marking published_verified; inspect releaseURL on the destination when available. An asynchronous failure can still leave ERROR.
Only after manual readback works should you add a Schedule Trigger, explicitly check workflow timezone, and publish the workflow. Keep unresolved rows visible to the operator.
I am PostSider’s founder. My recommendation is to prove one row’s receipt before expanding the workflow. See the PostSider n8n workflow.
For the broader pattern, including client work and cost, the automation operator profile covers how consultants run this for several brands.
Lukasz Blania is the solo founder of PostSider, a social media scheduling product with a human dashboard and API access for developer workflows.
Automation at scale changes the bill, and the Post Bridge comparison prices 100 and 150 accounts.
Frequently asked questions
Do I need a PostSider-specific n8n node?
No. This workflow uses the standard HTTP Request node with a Header Auth credential and the PostSider REST API.
Can n8n schedule social media posts from Google Sheets?
Yes. Read approved rows, map their content and channel IDs into a scheduling request, then update each source row with its returned post ID and verified state.
Does a successful n8n run mean the post was published?
No. A successful scheduling request is not publication. Verify the stored post and check the publishing result after its scheduled time.
How do I avoid duplicates when a request times out?
Persist the exact request and a stable Idempotency-Key before sending it. Recover the same operation with the same body and key instead of generating a new operation.
Can I use the same payload for every social network?
No. Start with one verified channel and read its integration settings. Other networks can require different settings or media, so changing only the channel ID is insufficient.