Works with all AI agents

Connect your AI agent

Choose your assistant and connect in seconds.

Add to Claude

Works on every Claude plan, including free. Full Claude guide

  1. 1

    Add to Claude

    One click from Claude’s connector directory.

  2. 2

    Sign up or sign in

    Takes seconds, then you’re sent straight back.

  3. 3

    Ask Claude

    “Plan and schedule my posts for next week.”

How to Verify PostEverywhere Webhook Signatures (Node, Python, Ruby)

Your webhook endpoint is a public URL, so anyone can POST to it. Verify every PostEverywhere delivery with HMAC-SHA256 over the raw body, a constant-time compare and a timestamp check. Working code in three languages.

Jamie Partridge
Jamie Partridge
Founder··7 min read
Verifying a PostEverywhere webhook signature with HMAC-SHA256 in Node, Python and Ruby

Your webhook endpoint is a URL on the public internet. Nothing about receiving a POST proves it came from us. If you act on an unverified payload, anyone who guesses or discovers that URL can send you a fake post.published and your system will believe a post went live that never existed.

You verify a PostEverywhere webhook signature in about fifteen lines, and that closes the hole entirely. Every PostEverywhere delivery carries an X-PostEverywhere-Signature header containing an HMAC-SHA256 of the exact bytes we sent, computed with a secret only you and PostEverywhere hold.

This guide shows the verification in Node, Python and Ruby, then covers the three mistakes that account for almost every "the signature never matches" support thread.

Written by Jamie Partridge, Founder.

What PostEverywhere signs, and with what

When you create a webhook through the social media API, the response contains a secret beginning whsec_. That value is returned once and never again, so save it to your secret store immediately.

There is no endpoint to look it up later; losing it means deleting the webhook and creating another.

On every delivery, PostEverywhere computes HMAC-SHA256 over the raw response body using that secret, and sends it as:

X-PostEverywhere-Signature: sha256=<hex digest>

HMAC is specified in RFC 6234. The property you are relying on is that computing a valid digest requires the secret, so a matching signature proves both that the payload came from PostEverywhere and that nothing altered it in transit.

Two other headers matter for a complete check:

Header Why you need it
X-PostEverywhere-Timestamp Unix epoch seconds. Reject anything older than ~5 minutes
X-PostEverywhere-Event-Id Stable UUID. Dedupe on it, because delivery is at-least-once

Not set up yet? The PostEverywhere webhooks guide covers every event, the payload shape and the retry ladder. Webhooks are included on every plan from $9/mo. Start a 7-day free trial: card required, $0 charged today.

Verify a PostEverywhere webhook in Node.js

import crypto from "crypto";

function verifyWebhook(rawBody: string, signatureHeader: string, secret: string): boolean {
  if (!signatureHeader || !signatureHeader.startsWith("sha256=")) return false;

  const provided = signatureHeader.slice(7);
  const expected = crypto.createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");

  try {
    return crypto.timingSafeEqual(
      Buffer.from(provided, "hex"),
      Buffer.from(expected, "hex"),
    );
  } catch {
    return false;
  }
}

The try/catch is not decoration. crypto.timingSafeEqual throws if the two buffers differ in length, which is exactly what happens when someone sends a malformed signature. Catching it and returning false turns a crash into a rejection.

In Express, the body must reach you unparsed:

app.post('/webhooks/posteverywhere',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const ok = verifyWebhook(
      req.body.toString('utf8'),
      req.get('x-posteverywhere-signature') || '',
      process.env.PE_WEBHOOK_SECRET,
    );
    if (!ok) return res.status(401).send('Invalid signature');
    res.status(200).send('OK');
  });

Verify a PostEverywhere webhook in Python

import hmac
import hashlib

def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
    if not signature_header or not signature_header.startswith("sha256="):
        return False
    provided = signature_header[7:]
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(provided, expected)

hmac.compare_digest is the constant-time comparison. Note that raw_body is bytes, not str: in Flask that is request.get_data(), and in FastAPI it is await request.body(). Reading request.json first and re-encoding it is the single most common way to break this.

Verify a PostEverywhere webhook in Ruby

require "openssl"

def verify_webhook(raw_body, signature_header, secret)
  return false unless signature_header&.start_with?("sha256=")
  provided = signature_header.sub("sha256=", "")
  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
  Rack::Utils.secure_compare(provided, expected)
end

In Rails, reach for request.raw_post rather than params, since params has already been parsed and merged. The same rule applies whichever language drives the social media API.

Three mistakes that break PostEverywhere signature checks

Re-serialising the JSON. The HMAC is computed over bytes. Parsing a payload and stringifying it again can reorder keys, change whitespace, or alter number formatting, and the digest will not match.

Always hash the raw body exactly as received, before any middleware touches it. This is the cause in most cases where the code looks correct.

Comparing with ==. A naive comparison returns as soon as it finds a differing byte, so the time it takes leaks how much of the prefix was right. That is enough to forge a signature one byte at a time. Use crypto.timingSafeEqual, hmac.compare_digest or Rack::Utils.secure_compare.

Skipping the timestamp. A valid signature stays valid forever. Someone who captures one delivery can replay it next week and it will verify perfectly. The timestamp check is what makes the signature time-bounded.

There is a fourth that is less a mistake than an omission: not deduping. PostEverywhere delivers at-least-once, so the same event can legitimately arrive twice. Without a dedup key you will double-post, double-charge, or double-notify on the rare retry.

Blocking replays with the PostEverywhere timestamp header

const timestamp = Number(req.headers.get('x-posteverywhere-timestamp'));
const ageSeconds = Math.abs(Date.now() / 1000 - timestamp);
if (!timestamp || ageSeconds > 300) {
  return new Response('Timestamp outside tolerance', { status: 401 });
}

Five minutes is a reasonable window. It is generous enough to survive clock skew between your server and ours, and tight enough that a captured payload is useless almost immediately.

Combine it with the signature check and you have both properties you need: the payload is authentic, and it is recent. Neither alone is sufficient.

Want the whole developer surface? The developer docs cover every endpoint, and the full webhooks reference carries the complete event list and payload shapes.

The full PostEverywhere webhook handler, in order

Order matters, and each step exists for a reason:

// 1. Raw body BEFORE parsing, or the HMAC will not match.
const rawBody = await req.text();

// 2. Verify authenticity.
if (!verifyWebhook(rawBody, req.headers.get('x-posteverywhere-signature') || '', secret)) {
  return new Response('Invalid signature', { status: 401 });
}

// 3. Verify recency.
const ts = Number(req.headers.get('x-posteverywhere-timestamp'));
if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) {
  return new Response('Stale delivery', { status: 401 });
}

// 4. Dedupe: delivery is at-least-once.
const eventId = req.headers.get('x-posteverywhere-event-id');
if (await alreadyProcessed(eventId)) return new Response('OK', { status: 200 });

// 5. Enqueue and return fast: you have 10 seconds.
await queue.add('posteverywhere-event', JSON.parse(rawBody));
return new Response('OK', { status: 200 });

Step 5 is the one people skip. PostEverywhere allows 10 seconds before treating a delivery as failed and starting the retry ladder. If your handler publishes to a database, calls a third party, then sends an email inline, you will breach that under load and start receiving duplicates you caused yourself.

Verify, dedupe, enqueue, respond; do the work in a worker.

For what happens when deliveries do fail, and for wiring webhooks into an AI agent instead of polling, our PostEverywhere webhooks guide covers the retry ladder, recovery and the agent pattern.

FAQs

How do I verify a PostEverywhere webhook signature?

Compute HMAC-SHA256 over the raw request body using your whsec_ signing secret, then compare the hex digest to the value after sha256= in the X-PostEverywhere-Signature header using a constant-time comparison. Reject anything that does not match.

Why does my PostEverywhere signature never match?

Almost always because the body was parsed and re-serialised before hashing. The HMAC covers exact bytes, so re-encoded JSON produces a different digest even when the data is identical. Capture the raw body before any JSON middleware runs.

Where do I get the PostEverywhere webhook signing secret?

It is returned once, in the POST /v1/webhooks create response, beginning whsec_. There is no API to retrieve it later. If it is lost, delete that webhook and create a new one.

Do I need to check the timestamp as well as the signature?

Yes, if you care about replay. A signature stays valid indefinitely, so a captured delivery can be re-sent later and will verify. Rejecting deliveries where X-PostEverywhere-Timestamp is more than about five minutes old closes that gap.

Why is a constant-time compare necessary?

A normal string comparison exits at the first differing byte, so its duration reveals how many leading bytes were correct. An attacker can use that to reconstruct a valid signature incrementally. Constant-time comparison takes the same time regardless of where the difference is.

Can the same PostEverywhere event arrive twice?

Yes. Delivery is at-least-once, so retries after a timeout can duplicate an event your handler already processed. Dedupe on X-PostEverywhere-Event-Id, which is stable for a given event across every retry.

What status code should my webhook endpoint return?

Any 2xx within 10 seconds marks the delivery successful. Return 401 for a failed signature check so the rejection is visible in your logs, and avoid returning 5xx for application errors you do not want retried six times.


Verification is fifteen lines, it is the difference between a trustworthy event stream and a public write endpoint, and it takes longer to read this page than to implement.

Start your 7-day free trial, create a webhook, and send yourself a test delivery to confirm the check passes before anything real depends on it.

The PostEverywhere webhooks guide covers every event and the retry rules, and the API automation guide walks the rest of the REST surface.

Jamie Partridge
Written by Jamie Partridge

Founder & CEO of PostEverywhere. Writing about social media strategy, publishing workflows, and analytics that help brands grow faster.

Related articles

Developers

Social Media Webhooks: Know the Moment a Post Publishes or Fails

Stop polling for publish status. PostEverywhere webhooks push a signed JSON payload the moment a post publishes, a destination fails, or a social account needs reconnecting. Every event, payload, header and retry rule.

16 min read
Developers

Social Media API Rate Limits Compared (2026)

Every major social API meters you differently: requests per window, points per hour, quota units per day, or a daily post cap. Here is what each one actually allows, with the figures from their own docs.

12 min read

Social media scheduler

Post everywhere from one dashboard

Schedule a month of posts in one sitting, tailor each one per platform, and let your AI agent handle the rest.

  • Unlimited posts to all top platforms
  • AI Studio for posts, images and video
  • Connect ChatGPT, Claude or any AI agent

7-day free trial on every plan. $0 due today, cancel anytime.

  • Lite

    2 social accounts

    $9/mo

  • Starter

    5 social accounts

    $19/mo

  • GrowthRecommended

    10 social accounts

    $29/mo

  • Scale

    20 social accounts

    $39/mo

Compare every plan