# 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.
**Source:** https://posteverywhere.ai/blog/verify-posteverywhere-webhook-signatures
**Author:** Jamie Partridge
**Published:** 2026-09-12
---
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.*
## Table of Contents
1. [What PostEverywhere signs, and with what](#what-posteverywhere-signs-and-with-what)
2. [Verify a PostEverywhere webhook in Node.js](#verify-a-posteverywhere-webhook-in-nodejs)
3. [Verify a PostEverywhere webhook in Python](#verify-a-posteverywhere-webhook-in-python)
4. [Verify a PostEverywhere webhook in Ruby](#verify-a-posteverywhere-webhook-in-ruby)
5. [Three mistakes that break PostEverywhere signature checks](#three-mistakes-that-break-posteverywhere-signature-checks)
6. [Blocking replays with the PostEverywhere timestamp header](#blocking-replays-with-the-posteverywhere-timestamp-header)
7. [The full PostEverywhere webhook handler, in order](#the-full-posteverywhere-webhook-handler-in-order)
8. [FAQs](#faqs)
## What PostEverywhere signs, and with what
When you create a webhook through the [social media API](/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=
```
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](/blog/posteverywhere-webhooks) covers every event, the payload shape and the retry ladder. Webhooks are included on [every plan from $9/mo](/pricing). [Start a 7-day free trial](https://app.posteverywhere.ai/signup): card required, $0 charged today.
## Verify a PostEverywhere webhook in Node.js
```ts
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:
```js
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
```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
```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](/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
```ts
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](/developers) cover every endpoint, and the full [webhooks reference](/docs/webhooks) carries the complete event list and payload shapes.
## The full PostEverywhere webhook handler, in order
Order matters, and each step exists for a reason:
```ts
// 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, [when social posts fail](/blog/when-social-posts-fail-webhooks) covers the retry ladder and recovery. If you are wiring this into an [AI agent](/agents) rather than a server, [why your AI agent should not poll PostEverywhere](/blog/ai-agents-webhooks-not-polling) covers that 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](https://app.posteverywhere.ai/signup), create a webhook, and send yourself a test delivery to confirm the check passes before anything real depends on it.
The [PostEverywhere webhooks guide](/blog/posteverywhere-webhooks) covers every event and the retry rules, and the [social media scheduling API guide](/blog/social-media-scheduling-api-guide) walks the rest of the REST surface.