Search

Search the docs…

Build

Webhooks & RSS

Get a signed HTTP request when a post is scheduled, goes out or fails, and turn new items of an RSS or Atom feed into posts automatically.

Where
Both live in Automations (Automatizaciones) inside each project. Owners and admins manage them; editors can see them.

Outgoing webhooks

Each project can have up to 10 endpoints. Postika sends a POST with a JSON body to your https:// URL for every event the endpoint listens to. Answer with any 2xx within 10 seconds.

  1. 1
    Open Automations → Webhooks → Add webhook (Automatizaciones → Webhooks → Añadir webhook).
  2. 2
    Paste your URL, pick the events and create it.
  3. 3
    Copy the signing secret (whsec_…). It's shown once.
  4. 4
    Use Send test event (Enviar evento de prueba) to check your endpoint. It sends a ping event.

Events

EventWhen
post.scheduledA post is scheduled for a future time (by you, the API, an AI agent, or an approval).
post.pending_approvalA post is waiting for an admin to approve it.
post.publishedA post went out on all its channels.
post.partialA post went out on some channels and failed on others.
post.failedA post failed on all its channels.
channel.expiredA connected channel's session expired or was revoked. Reconnect it in Postika.
plug.firedA plug fired on a post that reached its threshold (see Plugs below).
pingTest event sent from the app. Not retried.

Payload

Every request has the same envelope: id (unique per event), type, created_at, project and data. Post events carry data.post; channel events carry data.channel. The post reflects its state when the delivery was prepared. Bodies are capped at 256 KB.

post.published
{
  "id": "6f1c2b3a-9d4e-4f6a-8b7c-1d2e3f4a5b6c",
  "type": "post.published",
  "created_at": "2026-11-12T09:30:04.112Z",
  "project": {
    "id": "0b7e…",
    "name": "Acme"
  },
  "data": {
    "post": {
      "id": "c3a1…",
      "status": "published",
      "content": "New on the blog: How we cut our build time in half",
      "scheduled_at": "2026-11-12T09:30:00.000Z",
      "created_at": "2026-11-11T18:02:41.000Z",
      "updated_at": "2026-11-12T09:30:04.090Z",
      "source": "manual",
      "note": null,
      "media": [
        {
          "id": "9e2d…",
          "kind": "image"
        }
      ],
      "channels": [
        {
          "channel_id": "8d0c6f8e-2b7a-4c1e-9f43-2f1d7a8b5c10",
          "network": "linkedin",
          "handle": "acme",
          "status": "published",
          "url": "https://www.linkedin.com/feed/update/urn:li:share:…",
          "error": null,
          "published_at": "2026-11-12T09:30:03.870Z"
        }
      ]
    }
  }
}
channel.expired
{
  "id": "…",
  "type": "channel.expired",
  "created_at": "2026-11-12T08:00:00.000Z",
  "project": {
    "id": "0b7e…",
    "name": "Acme"
  },
  "data": {
    "channel": {
      "id": "8d0c…",
      "network": "instagram",
      "handle": "acme",
      "name": "Acme",
      "status": "expired"
    }
  }
}

Headers

Content-Type
application/json
User-Agent
Postika-Webhooks/1.0
Postika-Event
The event type, e.g. post.published
Postika-Delivery
Unique ID of this delivery. Same value on retries of the same delivery.
Postika-Timestamp
Unix time (seconds) when this attempt was signed.
Postika-Signature
t=<timestamp>,v1=<hex HMAC-SHA256>

Verify the signature

Compute HMAC-SHA256(secret, timestamp + "." + raw_body) in hex and compare it, in constant time, with each v1 value of Postika-Signature. Reject requests whose t is more than 5 minutes away from your clock. Always use the raw body bytes, not re-serialized JSON.

import crypto from "node:crypto"
import express from "express"

const app = express()
const SECRET = process.env.POSTIKA_WEBHOOK_SECRET // whsec_…

// Use the raw body: the signature covers the exact bytes Postika sent.
app.post("/webhooks/postika", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("Postika-Signature") ?? ""
  const parts = Object.groupBy(header.split(","), (p) => p.split("=")[0])
  const t = Number(parts.t?.[0]?.slice(2))
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(400)

  const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${req.body}`).digest("hex")
  const ok = (parts.v1 ?? []).some((p) => {
    const sig = Buffer.from(p.slice(3), "hex")
    return sig.length === 32 && crypto.timingSafeEqual(sig, Buffer.from(expected, "hex"))
  })
  if (!ok) return res.sendStatus(400)

  const event = JSON.parse(req.body)
  // Deliveries can repeat: use event.id (or the Postika-Delivery header) to skip duplicates.
  console.log(event.type, event.data)
  res.sendStatus(200)
})

Rotating the secret

Rotate secret (Cambiar secreto) gives you a new secret right away. For the next 24 hours every request carries two v1 signatures (new and old), so you can deploy the new secret without dropping events.

Retries and auto-disable

  • A delivery fails if your server doesn't answer 2xx within 10 seconds (redirects count as failures and aren't followed).
  • Failed deliveries are retried 5 more times: after about 1 minute, 10 minutes, 1 hour, 6 hours and 16 hours (6 attempts over ~23 hours, with a little jitter).
  • Deliveries can arrive more than once and out of order: deduplicate on the event id.
  • After 15 failed attempts in a row the endpoint is turned off and the app shows a warning. Fix your server and turn it back on; you can retry failed deliveries from the log.
  • The log keeps each endpoint's recent deliveries (status code, time, attempts) for 30 days.
Network rules
URLs must be https:// on port 443 or 1024–65535, without a user or password. Postika resolves the host and refuses private, loopback and link-local addresses (also after DNS), so internal services can't be targeted.

RSS auto-post

Add up to 10 RSS 2.0, RSS 1.0 or Atom feeds per project. Postika checks each feed every 30 minutes (with ETag / Last-Modified, so unchanged feeds cost nothing) and creates a post for every new item.

  • No backfill. When you add a feed, everything already in it is marked as seen. Only items published afterwards become posts.
  • Items are matched by guid/id and by link (without #… or utm_*), so an item is never posted twice even if one of them changes.
  • At most 5 posts per check: if a feed suddenly shows more new items, the newest 5 are posted and the rest are skipped.
  • Feeds must be https, up to 2 MB, and answer within 15 seconds. Up to 3 redirects are followed, each one checked again.

Template

The post text comes from a template. Default: {title} {link}.

TagReplaced with
{title}The item's title, as plain text.
{link}The item's link.
{description}Summary as plain text, up to 300 characters.
{feed}The feed's title.

What happens to each item

ModeResult
DraftA draft you review and schedule yourself.
Needs approvalA post waiting for approval, at the chosen time (now or up to 24 h later).
ScheduleScheduled at the chosen time. If a channel rejects the text or media (too long, Instagram without an image…), it's saved as a draft instead.

Posts created from a feed have source "rss" in webhook payloads. With Use the article's image (Usar la foto del artículo), the item's image (enclosure, media:content or the first <img>) is downloaded with the same rules as media_urls; if it can't be used, the post goes out without it.

A feed only posts while the project's subscription is active. If the person who set it up is no longer an admin, scheduled items wait for approval instead.

Plugs

A plug reacts to how a published post is doing. Pick a channel (or any channel of the project), a number and a time window, for example 50 likes within 7 days, and what to do when a post gets there. Up to 20 plugs per project, managed in Automations → Plugs (Automatizaciones → Plugs).

ActionWhere it works today
Reply to the postBluesky (a reply from the same account) and LinkedIn (a comment). Instagram, Facebook, Threads and YouTube need permissions Postika doesn't request yet.
Repost from other accountsAnother account of the same network in the project: Bluesky repost, LinkedIn reshare. Other networks don't let one account share another's post through their API.
WebhookSends plug.fired to your endpoints that listen to it, with the plug, the post and the result of each action.
  • The numbers are the ones analytics already reads: likes, reposts and comments on Bluesky; likes and comments on Instagram and YouTube; likes, comments and shares on Facebook. LinkedIn, Threads and TikTok don't share post numbers with the current permissions, so they can't trigger a plug yet.
  • Plugs are checked right after every analytics refresh (recent posts are refreshed every few hours), or at once with Check now (Comprobar ahora).
  • Once per post. Each fire is recorded before anything is done, so a plug never acts twice on the same post, even if you edit it or the numbers keep growing. Posts in the window that already qualify count too.
  • Failed actions aren't retried (a duplicate reply is worse than a missing one). The history shows each fire, the number reached and the result of every action.
  • On demo channels the actions are simulated. Plugs only run while the project's subscription is active.
plug.fired (data)
{
  "plug": {
    "id": "3f6c…",
    "name": "Boost viral posts",
    "metric": "likes",
    "threshold": 50,
    "within_days": 7
  },
  "run": {
    "id": "9a1e…",
    "status": "done",
    "value": 64,
    "channel_id": "b2d1…",
    "url": "https://bsky.app/profile/you.bsky.social/post/3l…",
    "actions": [
      {
        "action": "reply",
        "channelId": "b2d1…",
        "ok": true,
        "url": "https://bsky.app/profile/you.bsky.social/post/3m…"
      },
      {
        "action": "repost",
        "channelId": "c7a0…",
        "ok": true
      },
      {
        "action": "webhook",
        "ok": true
      }
    ],
    "fired_at": "2026-11-07T12:00:00.000Z"
  },
  "post": {
    "id": "…",
    "status": "published",
    "content": "…",
    "channels": []
  }
}