Skip to content
Postastory

Postastory · Docs

Webhooks

A webhook channel sends each approved post or article to your own address as signed JSON at its scheduled time, so your website, Zapier or Make can publish it.

The request

Postastory sends POST to the address you entered (HTTPS only) withContent-Type: application/json and these headers:

  • X-Postastory-Event: content.publish, or content.test for test sends.
  • X-Postastory-Delivery: a unique id. Retries of the same post send the same id — store it and ignore repeats.
  • X-Postastory-Timestamp: Unix time in seconds when the request was signed.
  • X-Postastory-Signature: sha256= followed by the hex HMAC-SHA256 of {timestamp}.{raw body} with your signing secret.
  • User-Agent: Postastory-Webhook/1.0

contentType is social_post (with text) or blog_article (withtitle, html, excerpt, slug, metaDescription andtags). Images are public HTTPS URLs; articles mark their image "role": "featured".

{
  "event": "content.publish",
  "id": "evt_6f1c2a…",
  "createdAt": "2026-11-02T10:00:00Z",
  "project": { "id": "0193…", "name": "Demo Bakery" },
  "item": {
    "id": "0194…",
    "version": 3,
    "contentType": "blog_article",
    "title": "Why rye bread keeps longer",
    "slug": "why-rye-bread-keeps-longer",
    "excerpt": "Rye holds water better than wheat…",
    "html": "<h2>…</h2><p>…</p>",
    "text": null,
    "metaDescription": "…",
    "tags": ["bread", "rye"],
    "images": [
      { "url": "https://…/media/….jpg", "alt": "A rye loaf", "width": 1536, "height": 1024, "role": "featured" }
    ],
    "scheduledFor": "2026-11-02T10:00:00Z"
  }
}

Verify the signature

  1. Read the raw request body before parsing it.
  2. Reject the request if the timestamp is missing or more than 5 minutes away from your clock.
  3. Compute HMAC-SHA256(secret, timestamp + "." + rawBody) as lowercase hex and compare it with the signature in constant time.

Your signing secret (whsec_…) is shown once when you add the webhook. Lost it? Use “New secret” on the channel; the old one stops working at once.

C#

using System.Security.Cryptography;
using System.Text;

// rawBody: the request body exactly as received (read it before any JSON parsing).
static bool IsValidPostastoryRequest(string secret, string timestamp, string signature, string rawBody)
{
    if (!long.TryParse(timestamp, out var seconds) ||
        Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - seconds) > 300)
    {
        return false; // missing or older than 5 minutes: possibly replayed
    }

    if (!signature.StartsWith("sha256=", StringComparison.Ordinal))
    {
        return false;
    }

    var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}"));
    try
    {
        return CryptographicOperations.FixedTimeEquals(expected, Convert.FromHexString(signature["sha256=".Length..]));
    }
    catch (FormatException)
    {
        return false;
    }
}

Node.js (Express)

import crypto from 'node:crypto';
import express from 'express';

const secret = process.env.POSTASTORY_WEBHOOK_SECRET;

function isValidPostastoryRequest(timestamp, signature, rawBody) {
  if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature ?? '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();
// express.raw keeps the exact bytes; JSON.parse only after the check.
app.post('/postastory', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  if (!isValidPostastoryRequest(req.get('X-Postastory-Timestamp'), req.get('X-Postastory-Signature'), raw)) {
    return res.status(401).end();
  }
  const delivery = req.get('X-Postastory-Delivery'); // store it: the same id may arrive again
  const { item } = JSON.parse(raw);
  // … create the post, then optionally tell us where it is:
  res.json({ id: 'your-post-id', url: 'https://your-site.example/blog/' + item.slug });
});

PHP

<?php
$secret = getenv('POSTASTORY_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_POSTASTORY_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_POSTASTORY_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $raw, $secret);

if ($timestamp === '' || abs(time() - (int) $timestamp) > 300 || !hash_equals($expected, $signature)) {
    http_response_code(401);
    exit;
}

$delivery = $_SERVER['HTTP_X_POSTASTORY_DELIVERY'] ?? ''; // dedupe on this id
$payload = json_decode($raw, true);
// … create the post …
header('Content-Type: application/json');
echo json_encode(['id' => 'your-post-id', 'url' => 'https://your-site.example/blog/' . $payload['item']['slug']]);

Python

import hashlib
import hmac
import os
import time

SECRET = os.environ["POSTASTORY_WEBHOOK_SECRET"]

def is_valid_postastory_request(timestamp: str, signature: str, raw_body: bytes) -> bool:
    """raw_body: the request body bytes exactly as received (Flask: request.get_data())."""
    try:
        if abs(time.time() - int(timestamp)) > 300:
            return False
    except (TypeError, ValueError):
        return False
    expected = "sha256=" + hmac.new(SECRET.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")

Respond and retries

  • Any 2xx within 15 seconds means delivered. Optionally answer { "id": "…", "url": "https://…" } and we show the link in Postastory.
  • 5xx, 408, 429 or a timeout: we retry after about 1, 5, 15, 30 and 60 minutes with the same delivery id.
  • Other 4xx (for example 400 or 401): we don't retry; the owner sees the error and can retry by hand.

Use “Send test” on the channel to receive a content.test delivery with sample content and see what your side answered.