Connect a webhook

Send SerpPal posts as signed JSON to Zapier, Make, n8n or your own code: the full payload, the reply format, and signature checks in Node, PHP and Python.

A webhook lets SerpPal send each post to an address you choose, as a JSON message. Use it to reach platforms that don't have a built-in connection (Medium, Wix, HubSpot, Notion, a custom CMS and so on) through Zapier, Make or n8n, or with a few lines of your own code.

Connect a webhook

  1. Get the address that should receive the posts, for example a Zapier "Catch Hook" address or your own endpoint. It must start with https:// (or http://) and be reachable from the internet.
  2. In SerpPal, open your project, click Project settings, and under Publishing click Connect a site.
  3. Choose Webhook and fill in:
FieldWhat to enter
Webhook addressThe address that receives the posts, for example https://hooks.zapier.com/hooks/catch/....
Signing secretOptional. Any long random text. When it is set, every request carries an X-SerpPal-Signature header so your receiver can check the message really came from SerpPal.
  1. Click Connect. SerpPal sends a test event to the address. The connection is saved only if your receiver answers with a success code (any 2xx status). The connection is named after the address's host, for example hooks.zapier.com.

The request

Every message is an HTTP POST with these headers:

HeaderValue
Content-Typeapplication/json
User-AgentSerpPal-Webhook/1.0
X-SerpPal-Signaturesha256= followed by the hex HMAC-SHA256 of the raw body, made with your signing secret. Only sent when a secret is set.

SerpPal waits up to 60 seconds for an answer.

Events

eventWhen it is sent
testWhen you click Connect, Save and test or Test. Only event and sentAt are included. Answer with any 2xx status.
publishThe first time an article is published to this webhook.
updateWhen the same article is published to this webhook again (for example after you click Update on site). post.id is the id from the first time, so you can find and update the same post.

A test event looks like this:

json
{ "event": "test", "sentAt": "2026-11-05T09:30:00.000Z" }

The publish and update payload

json
{
  "event": "publish",
  "post": {
    "id": "6f1c2a8e-3b7d-4e0a-9c55-2f8e1d4b7a90",
    "title": "How to Make Cold Brew Coffee at Home",
    "html": "<p>Cold brew is coffee steeped in cold water...</p><h2>The best cold brew ratio</h2><figure><img src=\"https://serppal.com/m/Xk2...jpg\" alt=\"Coffee beans\" loading=\"lazy\"></figure><p>Use one part coffee...</p>",
    "excerpt": "Learn how to make smooth cold brew coffee at home.",
    "slug": "cold-brew-coffee",
    "tags": ["coffee", "recipes"],
    "categories": ["Guides"],
    "status": "publish",
    "featuredImage": {
      "url": "https://serppal.com/m/Ab9...jpg",
      "alt": "A jar of cold brew coffee",
      "mime": "image/jpeg"
    },
    "media": [
      {
        "type": "image",
        "url": "https://serppal.com/m/Xk2...jpg",
        "embedHtml": null,
        "mime": "image/jpeg",
        "alt": "Coffee beans",
        "caption": "",
        "position": "h:1",
        "afterHeading": "The best cold brew ratio"
      },
      {
        "type": "embed",
        "url": "https://www.youtube.com/watch?v=abc123xyz",
        "embedHtml": "<div class=\"video-embed\"><iframe src=\"https://www.youtube.com/embed/abc123xyz\" ...></iframe></div>",
        "mime": null,
        "alt": "Cold brew in 60 seconds",
        "caption": "How we brew it",
        "position": "end",
        "afterHeading": null
      }
    ]
  },
  "sentAt": "2026-11-05T09:30:00.000Z"
}

Every field

FieldTypeWhat it holds
eventstringpublish or update.
sentAtstringWhen SerpPal sent the message (ISO 8601, UTC).
post.idstringOn the first publish, a new id made by SerpPal. If your receiver answers with its own id, that id is used from then on. Updates always carry the id of the first publish.
post.titlestringThe post title from the publish dialog.
post.htmlstringThe article body as HTML, ready to use. The first H1 is removed, and the images and videos are already placed where you chose.
post.excerptstringThe excerpt (the meta description unless you changed it). Can be empty.
post.slugstringThe slug. Empty when you left it empty.
post.tagsarray of stringsTag names. Can be empty.
post.categoriesarray of stringsCategory names. Can be empty.
post.statusstringpublish for Live post, draft for Draft to review.
post.featuredImageobject or nullThe featured image: url, alt and mime.
post.mediaarrayEach image or video you placed, in the order you added them. Already included in post.html; listed here so you can copy the files.
media[].typestringimage, video (an uploaded file) or embed (a video link).
media[].urlstringFor files, SerpPal's public address of the file. For embeds, the link you gave.
media[].embedHtmlstring or nullFor YouTube and Vimeo links, the player HTML; for a direct video file link, a <video> tag. Null for uploaded files and other links.
media[].mimestring or nullThe file type, like image/webp or video/mp4. Null for embeds.
media[].altstringAlt text for images, or the title for videos.
media[].captionstringThe caption. Can be empty.
media[].positionstringtop, end, or h:N: after the article's heading number N, counting H2 and H3 headings from 0.
media[].afterHeadingstring or nullThe text of the heading the item goes after, when it goes after one.
Note

Files stay on SerpPal's addresses (https://serppal.com/m/...). The addresses are long and unguessable, and they keep working while the file is in your media library. If you want your own copies, download the files from post.media and replace the addresses in post.html.

Your reply

Answer with a 2xx status to say the post was received. You can also send back JSON with the post's id and address on your platform:

json
{ "id": "1234", "url": "https://blog.example.com/cold-brew-coffee" }
  • id (optional): SerpPal stores it and sends it as post.id in later update events.
  • url (optional): shown in SerpPal as the post's address, with an Open on the site button. It is also saved as the article's URL for rank tracking.

Both are optional. An empty body, or a body that isn't JSON, is fine.

What SerpPal does with other answers:

Your answerWhat happens
2xxThe post is marked Published (or published as draft).
4xx, for example 400, 401 or 422A refusal: the post is marked Failed at once with your message. Send {"message": "..."} to show a clear reason.
429 or 5xx, a timeout or no answerA temporary problem: SerpPal tries again after 5 minutes, then after 10 more. After 3 attempts in all, the post is marked Failed.

Check the signature

When you set a signing secret, compute the HMAC-SHA256 of the raw request body (the exact bytes, before parsing the JSON) with your secret, write it as lowercase hex, add sha256= in front and compare it to the X-SerpPal-Signature header. Use a constant-time comparison.

Node.js (Express)

js
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.SERPPAL_SECRET;

// express.raw keeps the exact bytes, which the signature is made from.
app.post("/serppal", express.raw({ type: "application/json" }), (req, res) => {
  const got = req.get("X-SerpPal-Signature") || "";
  const want = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
  const ok = got.length === want.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want));
  if (!ok) return res.status(401).json({ message: "Bad signature" });

  const msg = JSON.parse(req.body.toString("utf8"));
  if (msg.event === "test") return res.json({ ok: true });

  // msg.event is "publish" or "update". Save msg.post in your CMS here.
  const id = msg.post.id;
  res.json({ id, url: `https://blog.example.com/${msg.post.slug}` });
});

app.listen(3000);

PHP

php
<?php
$secret = getenv('SERPPAL_SECRET');
$body = file_get_contents('php://input');
$got = $_SERVER['HTTP_X_SERPPAL_SIGNATURE'] ?? '';
$want = 'sha256=' . hash_hmac('sha256', $body, $secret);

header('Content-Type: application/json');
if (!hash_equals($want, $got)) {
    http_response_code(401);
    echo json_encode(['message' => 'Bad signature']);
    exit;
}

$msg = json_decode($body, true);
if ($msg['event'] === 'test') {
    echo json_encode(['ok' => true]);
    exit;
}

// $msg['event'] is "publish" or "update". Save $msg['post'] in your CMS here.
$id = $msg['post']['id'];
echo json_encode(['id' => (string) $id, 'url' => 'https://blog.example.com/' . $msg['post']['slug']]);

Python (Flask)

python
import hashlib
import hmac
import os

from flask import Flask, jsonify, request

app = Flask(__name__)
SECRET = os.environ["SERPPAL_SECRET"].encode()


@app.post("/serppal")
def serppal():
    body = request.get_data()  # the raw bytes
    want = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(want, request.headers.get("X-SerpPal-Signature", "")):
        return jsonify(message="Bad signature"), 401

    msg = request.get_json()
    if msg["event"] == "test":
        return jsonify(ok=True)

    # msg["event"] is "publish" or "update". Save msg["post"] in your CMS here.
    post = msg["post"]
    return jsonify(id=post["id"], url=f"https://blog.example.com/{post['slug']}")
Important

Compute the signature from the raw body. If you parse the JSON and turn it back into text, spacing or key order can change and the signature won't match.

Tips for Zapier, Make and n8n

Zapier

  • Start a Zap with Webhooks by Zapier and the Catch Hook trigger, copy the address it gives you, and paste it into SerpPal. Click Connect: the test event is the sample Zapier needs.
  • Add a filter step that continues only when event is publish (and update, if your next step can update posts), so test events don't create empty posts.
  • Map the post fields (title, html, excerpt, tags and so on) into your action steps.
  • Zapier answers every request with its own request id. SerpPal saves that as the post id, so in update events post.id is Zapier's id, not your platform's. Match updates on post.slug instead.
  • To check signatures in Zapier, use Catch Raw Hook (it keeps the raw body) with a Code by Zapier step.

Make

  • Start a scenario with a Webhooks module, Custom webhook, and paste its address into SerpPal. Click Connect so Make can learn the data structure from the test event.
  • Add a Webhook response module at the end to send back {"id": "...", "url": "..."} with your platform's post id and address.
  • Use a filter on event so test events stop early.

n8n

  • Use a Webhook node with the method set to POST and copy its production address into SerpPal. The workflow must be active.
  • To send back the post's id and address, set the Webhook node to respond with a Respond to Webhook node and add one at the end of the flow.
  • To check signatures, turn on the Webhook node's Raw Body option and compute the HMAC with a Crypto node or a Code node.

Troubleshooting

"Webhook: ..." with your receiver's message, or an HTTP code Your receiver answered with an error. A 4xx answer fails the post right away; fix the receiver and click Try again on the post. For a 401 or 403 answer SerpPal adds "(check the key or password)": if your receiver checks signatures, make sure it uses the same signing secret.

"That address is not reachable from the internet" SerpPal can't send to localhost, local network addresses or addresses that only work inside a VPN. To test locally, use a tunnel such as ngrok or Cloudflare Tunnel.

"Webhook did not answer in time" The receiver took more than 60 seconds. Answer first and do slow work afterwards (Zapier, Make and n8n do this for you).

Signatures don't match Check that you use the same secret, that you compute the HMAC over the raw body, and that you compare the full sha256=... text. If you changed the secret in SerpPal, update it in your receiver too.

More fixes are in Publishing troubleshooting.

Updated November 5, 2026Still stuck? Contact support