First 200 users get the Growth plan for $19/mo.

Claim
Browse the docs

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.

EventWhendata
post.scheduledA post was scheduled (or rescheduled) for a future time{ postId, scheduledAt, platforms }
post.publishedOne account shipped a post; one event per target{ postId, targetId, platform, connectionId, postedId, permalink, publishedAt }
post.failedOne account failed to publish a post; one event per target{ postId, targetId, platform, error: { code, message }, failedAt }
post.approval_requiredA scheduled post is waiting for a reviewer{ postId }
post.approvedA reviewer approved a pending post{ postId, approvedBy }
connection.errorA connected social account stopped working and needs a reconnect{ connectionId, platform, reason }
workflow.run.completedA workflow run finished{ workflowId, runId, summary }
workflow.run.failedA workflow run failed{ workflowId, runId, error }
proposal.stagedGloofy staged an action that needs a confirm{ actionId, toolName, title }
proposal.confirmedA 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

HeaderValue
X-Overads-SignatureHex HMAC-SHA256 of the raw request body, keyed with the endpoint secret
X-Overads-EventThe event name, same as event in the body
X-Overads-DeliveryThe delivery id. The same id on a retry, so use it to de-duplicate
Content-Typeapplication/json
User-Agentoverads-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.

Node (Express)javascript
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();
});
Python (Flask)python
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 "", 204

Answer 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-Delivery id.
  • 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 with PATCH /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/deliveries shows the last 50 with attempt, statusCode, error and nextAttemptAt; POST .../redeliver sends any of them again as a new delivery.

URL rules

  • https:// only. Plain http is refused.
  • No localhost or *.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_REFUSED with 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.