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.
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.
- 1Open Automations → Webhooks → Add webhook (Automatizaciones → Webhooks → Añadir webhook).
- 2Paste your URL, pick the events and create it.
- 3Copy the signing secret (
whsec_…). It's shown once. - 4Use Send test event (Enviar evento de prueba) to check your endpoint. It sends a
pingevent.
Events
| Event | When |
|---|---|
| post.scheduled | A post is scheduled for a future time (by you, the API, an AI agent, or an approval). |
| post.pending_approval | A post is waiting for an admin to approve it. |
| post.published | A post went out on all its channels. |
| post.partial | A post went out on some channels and failed on others. |
| post.failed | A post failed on all its channels. |
| channel.expired | A connected channel's session expired or was revoked. Reconnect it in Postika. |
| plug.fired | A plug fired on a post that reached its threshold (see Plugs below). |
| ping | Test 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.
{
"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"
}
]
}
}
}{
"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
2xxwithin 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.
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/idand by link (without#…orutm_*), 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}.
| Tag | Replaced 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
| Mode | Result |
|---|---|
| Draft | A draft you review and schedule yourself. |
| Needs approval | A post waiting for approval, at the chosen time (now or up to 24 h later). |
| Schedule | Scheduled 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.
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).
| Action | Where it works today |
|---|---|
| Reply to the post | Bluesky (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 accounts | Another 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. |
| Webhook | Sends 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": {
"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": []
}
}