Webhook-driven social media agent loops: closing the loop with HMAC

A social posting script that publishes and forgets is fine for newsletter-style cadences. A social agent needs to know whether the post actually went live, on which network, with what URL — and act on that. Polling is the obvious option, and it’s the wrong one: you burn API quota, you stall the next agent turn, and you pay for it in wall-clock latency. Webhooks are the right answer.

Posta’s outbound webhooks fire the moment a post’s status changes. Every payload is HMAC-signed. This post is about how to use them to close the loop on an agent — what patterns work, what code to write, and what to avoid.

Why webhooks beat polling

  • Latency. Webhooks fire within seconds of the platform confirming. Polling on a 30-second interval averages 15 seconds of dead time per check.
  • Cost. Each poll is an API call against your Posta quota and (worse) an LLM round-trip if the agent is the one polling.
  • Correctness. Polling needs you to track "last seen status" per post in your own state. Webhooks ship the new status in the payload.

What you get on a webhook

Every Posta outbound webhook includes: the event name (post.published, post.failed, post.scheduled, etc.), the platform, the platform post ID and URL (when published), the Posta post ID, and a timestamp. The headers carry an x-posta-signature HMAC-SHA256 over the raw body. See the developer reference for the full payload schema.

The minimum-viable receiver

A 30-line Node receiver that verifies the signature and dispatches by event type:

import { createHmac, timingSafeEqual } from 'node:crypto'
import express from 'express'

const app = express()
app.use(express.json({ verify: (req, _, buf) => { req.raw = buf } }))

app.post('/posta-webhook', (req, res) => {
  const sig = req.headers['x-posta-signature']
  if (!sig) return res.sendStatus(401)
  const expected = createHmac('sha256', process.env.POSTA_WEBHOOK_SECRET)
    .update(req.raw).digest('hex')
  const sigBuf = Buffer.from(sig)
  const expBuf = Buffer.from(expected)
  if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
    return res.sendStatus(401)
  }

  const { event, platform, platformPostUrl, postId } = req.body
  switch (event) {
    case 'post.published': onPublished(platform, platformPostUrl, postId); break
    case 'post.failed':    onFailed(platform, postId, req.body.error);     break
    default: /* ignore */ ;
  }
  res.sendStatus(200)
})
app.listen(3000)

Three things to notice. (1) The express.json middleware grabs the raw body before parsing, because HMAC has to verify the bytes that were signed, not the re-serialized JSON. (2) An early return on missing signature header prevents a confusing crash when something other than Posta probes the endpoint. (3) The length check before timingSafeEqual dodges a Node throw when buffers differ in length.

Pattern 1 — Auto-respond on publish

The most common closed-loop pattern: when LinkedIn fires post.published, hand the URL to your agent and have it draft the first reply or a follow-up Slack note. The webhook handler kicks off a new agent run rather than calling synchronously — keep the webhook handler fast (return 200 quickly) and put the work on a queue.

async function onPublished(platform, url, postId) {
  await queue.push({
    type: 'draft-reply',
    platform, url, postId,
    promptHint: 'Draft a thoughtful first comment on this post',
  })
}

Pattern 2 — Multi-day campaign branching

For a campaign that runs over days, you don’t want to schedule day 5 on day 1 — engagement on day 1 changes what day 5 should say. Webhook-driven branching lets the agent decide day N+1 after day N publishes:

  • Day 1 post fires post.published webhook.
  • Receiver kicks off agent run with the day-1 metrics so far (or just the URL — agent can fetch).
  • Agent drafts day-2 post and calls Posta to create it as a scheduled draft.
  • Repeat.

Pattern 3 — Retry-with-variation

When post.failed fires (rate limits, transient platform errors, content rejections), don’t blind-retry the same payload. Regenerate the caption with a different angle and re-schedule. The Posta queue itself does retry on transient platform errors with exponential backoff, so by the time you see a post.failed webhook the platform has truly refused — variation is the right next step.

Pattern 4 — Supervised autonomy with a kill switch

For higher-stakes content, fire a Slack message on every post.scheduled with a deep link to the post in the Posta dashboard (https://getposta.app/app/posts/<postId>). A reviewer can open it, edit, reschedule, or cancel before it goes live. The bot still drafts and schedules autonomously, but no post slips into production without at least the option of a human glance. This is the pattern we recommend for the first week of any new autonomous loop.

Pitfalls

  • Don’t do work synchronously in the receiver. Webhook senders time out; if you take 10 seconds to call an LLM in the handler, Posta will retry — and your downstream actions will fire twice.
  • Use idempotency keys. Every webhook payload includes a unique event ID. If your handler is at-least-once (most are), de-duplicate on that ID before acting.
  • Verify the signature on the raw body. Re-serializing the JSON before HMAC will fail intermittently — JSON whitespace and key order are not stable.
  • Don’t skip the signature check in dev. Skipping in dev means you ship the skip to prod. Use a dev secret and verify it the same way.
  • Return 200 on duplicates. If you’ve already processed the event, return 200 — Posta will treat a 4xx as a failure and retry.

Where to go from here

Wire the webhook receiver into an agent loop end-to-end in the autonomous social media bot tutorial. For a tour of the broader patterns this fits into, read agentic social media workflows. Or just grab a Posta token and write your handler.

Ready to simplify your social media workflow?

Join creators and teams who save hours every week with Posta.