Webhooks
Webhooks
Ten events, one envelope, an HMAC signature to verify, and a retry policy that pauses a dead endpoint.
Register an https endpoint, pick events, store the secret. overads POSTs a signed JSON envelope for every matching event. Endpoints are managed over the Webhooks API or in the app under Settings > Webhooks.
Events
Missing readings in a payload are null, never 0 or an empty string. permalink is null when the platform returned none.
| Event | When | data |
|---|---|---|
post.scheduled | A post was scheduled (or rescheduled) for a future time | { postId, scheduledAt, platforms } |
post.published | One account shipped a post; one event per target | { postId, targetId, platform, connectionId, postedId, permalink, publishedAt } |
post.failed | One account failed to publish a post; one event per target | { postId, targetId, platform, error: { code, message }, failedAt } |
post.approval_required | A scheduled post is waiting for a reviewer | { postId } |
post.approved | A reviewer approved a pending post | { postId, approvedBy } |
connection.error | A connected social account stopped working and needs a reconnect | { connectionId, platform, reason } |
workflow.run.completed | A workflow run finished | { workflowId, runId, summary } |
workflow.run.failed | A workflow run failed | { workflowId, runId, error } |
proposal.staged | Gloofy staged an action that needs a confirm | { actionId, toolName, title } |
proposal.confirmed | A member confirmed a staged Gloofy action | { actionId, toolName, title } |
ping is what POST /webhooks/:id/test sends, with data: { endpointId, message }. It cannot be subscribed to.
The envelope
Every delivery is one JSON object. version is "1" and stays "1" for as long as these field names hold.
{
"version": "1",
"event": "post.published",
"timestamp": "2026-09-14T16:00:04.212Z",
"data": {
"postId": "b6a0f6b2-1d0e-4d4a-8f2f-0c1f2a3b4c5d",
"targetId": "0c9e…",
"platform": "linkedin",
"connectionId": "3f1c1a2e-6b2a-4a0e-9a5f-0d5a7d0f2b11",
"postedId": "urn:li:share:7241…",
"permalink": "https://www.linkedin.com/feed/update/urn:li:share:7241…",
"publishedAt": "2026-09-14T16:00:04.000Z"
}
}Headers
| Header | Value |
|---|---|
X-Overads-Signature | Hex HMAC-SHA256 of the raw request body, keyed with the endpoint secret |
X-Overads-Event | The event name, same as event in the body |
X-Overads-Delivery | The delivery id. The same id on a retry, so use it to de-duplicate |
Content-Type | application/json |
User-Agent | overads-webhooks/1 |
Verify the signature
Compute HMAC-SHA256 over the raw bytes you received, with the whsec_... secret as the key, and compare the hex digest to the header with a constant-time comparison. Parse the JSON only after the comparison passes. Do not re-serialise the body before hashing; whitespace differences change the digest.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
app.post("/overads", express.raw({ type: "application/json" }), (req, res) => {
const expected = Buffer.from(
createHmac("sha256", process.env.OVERADS_WEBHOOK_SECRET).update(req.body).digest("hex"),
"hex",
);
let given;
try {
given = Buffer.from(String(req.get("X-Overads-Signature") ?? "").trim().toLowerCase(), "hex");
} catch {
return res.status(400).end();
}
if (given.length !== expected.length || given.length === 0 || !timingSafeEqual(given, expected)) {
return res.status(401).end();
}
const envelope = JSON.parse(req.body.toString("utf8"));
// envelope.event, envelope.data, req.get("X-Overads-Delivery")
res.status(204).end();
});import hashlib, hmac, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["OVERADS_WEBHOOK_SECRET"].encode()
@app.post("/overads")
def overads_webhook():
raw = request.get_data() # raw bytes, before any JSON parsing
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
given = (request.headers.get("X-Overads-Signature") or "").strip().lower()
if not given or not hmac.compare_digest(given, expected):
abort(401)
envelope = request.get_json(force=True)
# envelope["event"], envelope["data"], request.headers["X-Overads-Delivery"]
return "", 204Answer with any 2xx within 10 seconds. Anything else, including a timeout, counts as a failed attempt. Do the real work after you have responded, or on a queue.
Retries and auto-pause
- Three attempts per delivery: immediately, then 10 seconds after the first failure, then 1 minute after the second. Every attempt carries the same
X-Overads-Deliveryid. - Each failed attempt adds one to the endpoint's
consecutiveFailures; a successful send resets it to 0. - At 20 consecutive failures the endpoint pauses itself (
active: false), stops receiving events, and the workspace is notified. Resume it withPATCH /webhooks/:id { "active": true }, which resets the counter. - A paused endpoint's pending retries are recorded as failed with
Endpoint is paused; nothing is queued for it. GET /webhooks/:id/deliveriesshows the last 50 withattempt,statusCode,errorandnextAttemptAt;POST .../redeliversends any of them again as a new delivery.
URL rules
https://only. Plain http is refused.- No
localhostor*.localhost, no private, loopback or link-local address, no credentials in the URL. The rule is applied at registration, on every URL change, and again right before each send, with the resolved address pinned so DNS cannot change it between the check and the connection. - Redirects are not followed. Answer at the URL you registered.
- A refused URL answers
WEBHOOK_URL_REFUSEDwith the reason.
Deliveries are best-effort by design. If the queue cannot be reached when an event fires, the thing the event describes still happened; the notification is what was lost. Poll GET /posts for anything you cannot afford to miss.