← back to blog

Fully Automating Social Media Posting

Automation / Integration 10 min read
Home Office Space Products

Automating a Daily Social Media Pipeline with Logic Apps, Shopify, and Claude

I’ve been playing around with Claude, and wanted to see what’s possible in regards to ECommerce and Social Integrations. I set myself a weekend challenge of being able to set up an automated process that takes content from a Shopify site, and posts to social media regularly, consistently, on-brand, without a human having to interfere with it. I didn’t want a scheduling tool where you still write every post — I wanted a genuinely autonomous, hands-off, pipeline that picks a product, writes the copy, designs the graphic, and publishes to Facebook and Instagram on its own.

This is a write-up of how I built it: the architecture, the decisions I made, and the parts that took the most time. It’s a serverless system built on Azure Logic Apps, pulling from the Shopify Admin API, generating captions with Claude’s API, rendering branded images in an Azure Function, and publishing through Meta’s Graph API. The whole thing costs a few dollars a month to run.

Design goals

I set a handful of constraints up front, because they shaped every decision after:

Fully autonomous. No daily human step. If it needs me to click “approve” every morning, it’s failed its only real purpose.

Content sourced from the existing catalogue. The store already has every product, photo, description, and price in Shopify. Duplicating that into a separate content system would be a maintenance burden and a source of drift.

Captions that don’t read as automated. A templated `”Featured today: {title} — ${price}!”` is trivially easy and instantly recognisable as a bot. I wanted copy good enough that it could pass as human generated.

Branded, consistent imagery. Every post should look like it belongs to the same brand, generated programmatically.

Cheap and serverless. No VM to patch, no always-on service. Scale-to-zero, pay-per-execution.

Resilient and observable. It runs unattended, so it has to degrade gracefully and tell me when something breaks.

Architecture overview

The orchestrator is an Azure Logic App on the Consumption plan — a serverless workflow that fires on a daily schedule and chains together HTTP calls, data transformations, and connector actions without any hosting overhead. Around it sit three external services and one piece of custom compute:

Logic App (daily trigger)

  ├── Shopify Admin API        → product catalogue
  ├── Claude API               → caption generation
  ├── Azure Function (Node)    → branded image rendering → Blob Storage
  ├── Meta Graph API           → Facebook + Instagram publishing
  └── Azure Blob Storage       → state (the "recently posted" list)

The daily flow: trigger → fetch and paginate the catalogue → filter to eligible products → pick one at random → in parallel, generate the caption and render the image → post to Facebook → post to Instagram → record what was posted so it isn’t repeated.

Authenticating to Shopify

Shopify deprecated legacy custom-app tokens, so the current path is a Dev Dashboard app with explicit scopes (just `read_products` here) that you release as a version and install on the store. The interesting wrinkle: this app type doesn’t hand you a permanent token in any UI. Instead you use a *client credentials grant* — POST your client ID and secret to `/admin/oauth/access_token` with `grant_type=client_credentials` and get back a token that expires in ~24 hours.

That sounds like a hassle but it’s actually the better design. Rather than storing a long-lived secret-that-isn’t-really-secret, the workflow exchanges credentials for a fresh token at the start of every run:

POST https://{store}.myshopify.com/admin/oauth/access_token

{ "client_id": "...", "client_secret": "...", "grant_type": "client_credentials" }

The only durable secret is the client ID/secret pair, and every run is self-contained. That means there’s nothing to rotate manually, and nothing that silently goes stale.

Paginating and filtering the catalogue

The Shopify products endpoint caps at 250 items per page and uses cursor-based pagination via the `Link` response header. You’ll need to use an `Until` loop in the Logic App that follows the cursor:

Each iteration fetches the current page URL, unions the results into an accumulator variable, then parses the `Link` header for a `rel=”next”` cursor. When there’s no next link, the URL becomes empty and the loop exits. A small but important detail: the `next` URL from Shopify already encodes the original query — the limit, the `status=active` filter, the field selection — so subsequent pages stay filtered without you re-specifying anything.

Filtering down to eligible products happens in a single `Query` action with a compound condition:

greater(float(item()?['variants'][0]?['price']), 100)
  and not(equals(item()?['image'], null))
  and not(contains(excludedIds, item()?['id']))

Fore example, you can select products that are priced over $100, have an image, and are not in the recently-posted set. The price check being against live data means that with this example, a product that was $95 last month and is $120 today re-enters the eligible pool of products automatically, which a static exclusion list couldn’t do.

The “no repeats” state

State lives in a single JSON file in Blob Storage — an array of `{ productId, postedDate }`. At the start of a run the workflow reads it (gracefully defaulting to an empty list if the blob is missing, wrapped in a scope whose failure is caught downstream), prunes anything older than 14 days, and uses the remaining IDs as the exclusion set. After a successful post, it appends the new entry and writes the file back. The prune-on-read plus append-on-write means the file self-maintains — stale entries age out without a cleanup job.

The ordering is deliberate: the write-back only happens *after* both posts succeed. A failed post then doesn’t burn the product out of rotation and it’s eligible again next run.

Generating captions with Claude

With the early days of AI, I feel most of the time you could tell what had been generated by AI, and what was genuinely human generated. However, with the latest Claude models, it’s getting increasingly difficult. A great time to start testing the waters with full AI-Generated social media content!

The workflow sends the product’s real `body_html` description to Claude’s API with a prompt that specifies tone (warm, conversational, like a knowledgeable shop owner), structure (two short paragraphs, price woven in naturally, a few hashtags), and — importantly — explicit negative constraints. No corporate clichés, no hard sell, and a hard rule: quote a customer testimonial only if one genuinely appears in the description, never fabricate one. Marketing copy that invents social proof is both dishonest and a legal risk, and it was trivial to constrain against.

The response gets piped through a `coalesce` so that if the API call ever fails, the workflow falls back to a simple template caption rather than aborting the day’s post:

coalesce(
  body('Call_Claude')?['content']?[0]?['text'],
  concat('Featured today: ', title, ' — $', price, ...)
)

Graceful degradation as a default posture: the post still goes out, just less eloquently, on the rare day the AI service is unavailable.

Rendering branded images in an Azure Function

A caption needs a visual, and a raw product photo wasn’t enough — I wanted every post unmistakably branded.

I built a small Node Function using *sharp* that takes `{ imageUrl, title, price }` and composites a 1080×1080 graphic: the product photo on a white rounded card, logo and wordmark in the header, the price in a bold brand-colour pill, the site URL in the corner. The text layout is generated as an SVG layer (so I get real typography and precise control), then composited over the resized photo and flattened to JPEG — Instagram rejects PNG for image posts, a detail you only learn by hitting it.

A few things that earned their place:

Defensive text handling. Long titles wrap to two lines and ellipsise; the price pill repositions based on title height; prices always render to two decimal places. The renderer never produces a broken layout regardless of input.

Fonts bundled and registered. sharp’s SVG text rendering needs fontconfig pointed at the bundled font files via `FONTCONFIG_PATH`, or text silently falls back to a serif default. This is exactly the kind of environment-specific gotcha that doesn’t show up until you deploy.

The logo as a transparent mark. I extracted the logo to alpha transparency rather than compositing a flattened image, so it sits seamlessly on the brand background — and lets me change the background colour later with no logo artefacts.

The Function uploads the result to Blob Storage and returns a public URL. That public-fetchability is non-negotiable: Instagram’s API fetches the image from the URL you give it, anonymously.

Publishing to Meta

Facebook is the easy one: a single `POST /{page-id}/photos` with the image URL, caption, and a Page access token.

Instagram is a two-step container model. First `POST /{ig-user-id}/media` creates a media container from the image URL and caption; then `POST /{ig-user-id}/media_publish` publishes it by container ID. (Image containers are ready immediately — the status-polling loop you’ll read about in tutorials is video-only.)

Two things bit me here, both a little frustrating:

The *token saga* is the real work. Instagram publishing requires a Business account linked to a Facebook Page, an app with `instagram_basic`, `instagram_content_publish`, `pages_manage_posts`, and `pages_read_engagement`, and a *Page* access token — derived from a long-lived user token so it doesn’t expire — not a user token. Getting from “I have a Facebook account” to “I have a never-expiring Page token and the IG business account ID” is a multi-step dance through Graph API Explorer and the Token Debugger that’s far more of the project than the code is.

The *content-type quirk*: Graph API responses come back with `Content-Type: text/javascript`, not `application/json`. Please note that Logic Apps will treats the body as a string, and `body(‘CreateContainer’)?[‘id’]` fails with “property selection not supported on values of type String.” The fix is to wrap it: `json(body(‘CreateContainer’))?[‘id’]`.

Resilience and the 80/20 of the whole thing

The headline features — AI captions, generated imagery — were the quick 20%. The other 80% was authentication, pagination, error boundaries, content-type handling, and graceful degradation. That ratio is the actual lesson of the project.

Concretely, the resilience layer:

Retry policies on every external HTTP call — fixed-interval retries absorb transient failures and serverless cold starts (the first Function invocation of the day can be slow while sharp loads).

Scoped error boundaries so a missing state blob or a flaky read doesn’t kill the run; it continues with a safe default.

A coalesce fallback on the caption so an AI outage downgrades rather than breaks.

Idempotent-ish write ordering so partial failures don’t corrupt state or waste a product.

A failure-alert path so a silently expired token surfaces as a notification, not as someone eventually noticing the feed went quiet.

What to do next…

A few directions I find interesting from here that could be looked at:

But the core lesson stands as built: the genuinely hard, valuable engineering wasn’t the AI or the image generation. It was making something autonomous that you can trust to run alone — and trust is built in the error handling, not the feature list.

The full repository is private, but can be shared upon request. I’m keen to share and see if this is of use to anyone else!