Skip to content
← All articles
n8n social media scheduling: Sheets to queue

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:

  1. In PostSider Settings > API, create an organization API key. In current PostSider builds, named keys have selectable scopes: choose channels:read, posts:read and posts:write for this template. New keys default to read-only, so select the write scope explicitly. posts:write also 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, name Authorization, value the raw key without Bearer. Never paste it into JSON or a Code node.
  2. Choose that same credential in the three HTTP Request nodes (02 List connected channels, 04 Create ONE draft, 06 Read saved post).
  3. Edit only the 01 Safe demo settings Code 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.
  4. 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 is DRAFT, 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.

ColumnExample or initial value
record_idcampaign042
revision1
approval_statusapproved
channel_idYOUR_VERIFIED_X_CHANNEL_ID
contentYour approved announcement.
publish_at_utcREPLACE_WITH_FUTURE_UTC_ISO_TIMESTAMP
request_jsonEmpty until prepared
idempotency_keyEmpty until prepared
post_idEmpty until accepted
sync_stateready
last_errorEmpty

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, without Bearer.
  • 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 fieldn8n expression or literal
record_id{{ $('Load Prepared').first().json.record_id }}
post_id{{ $json.body[0].postId }}
sync_statepending_verification
last_errorEmpty 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 boundaryRecovery
POST timeout or 5xxBounded retry with unchanged saved request and key.
POST accepted, receipt write failedReplay saved request/key to recover the ID, then repair writeback.
ID saved, GET failedRetry GET only; never recreate.
400Inspect validation; deliberately approve and prepare a corrected revision.
401Fix the credential before retrying.
409Inspect invalid key, changed-body conflict or still-processing message; do not invent another key.
429Wait 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.

Let your AI post
to social media for you.

Connect your accounts, add PostSider to your AI and publish your first post in a few minutes.

7-day free trial · 30+ networks · No credit card