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
- Get the address that should receive the posts, for example a Zapier "Catch Hook" address or your own endpoint. It must start with
https://(orhttp://) and be reachable from the internet. - In SerpPal, open your project, click Project settings, and under Publishing click Connect a site.
- Choose Webhook and fill in:
| Field | What to enter |
|---|---|
| Webhook address | The address that receives the posts, for example https://hooks.zapier.com/hooks/catch/.... |
| Signing secret | Optional. 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. |
- Click Connect. SerpPal sends a
testevent 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 examplehooks.zapier.com.
The request
Every message is an HTTP POST with these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | SerpPal-Webhook/1.0 |
X-SerpPal-Signature | sha256= 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
event | When it is sent |
|---|---|
test | When you click Connect, Save and test or Test. Only event and sentAt are included. Answer with any 2xx status. |
publish | The first time an article is published to this webhook. |
update | When 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:
{ "event": "test", "sentAt": "2026-11-05T09:30:00.000Z" }The publish and update payload
{
"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
| Field | Type | What it holds |
|---|---|---|
event | string | publish or update. |
sentAt | string | When SerpPal sent the message (ISO 8601, UTC). |
post.id | string | On 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.title | string | The post title from the publish dialog. |
post.html | string | The article body as HTML, ready to use. The first H1 is removed, and the images and videos are already placed where you chose. |
post.excerpt | string | The excerpt (the meta description unless you changed it). Can be empty. |
post.slug | string | The slug. Empty when you left it empty. |
post.tags | array of strings | Tag names. Can be empty. |
post.categories | array of strings | Category names. Can be empty. |
post.status | string | publish for Live post, draft for Draft to review. |
post.featuredImage | object or null | The featured image: url, alt and mime. |
post.media | array | Each 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[].type | string | image, video (an uploaded file) or embed (a video link). |
media[].url | string | For files, SerpPal's public address of the file. For embeds, the link you gave. |
media[].embedHtml | string or null | For YouTube and Vimeo links, the player HTML; for a direct video file link, a <video> tag. Null for uploaded files and other links. |
media[].mime | string or null | The file type, like image/webp or video/mp4. Null for embeds. |
media[].alt | string | Alt text for images, or the title for videos. |
media[].caption | string | The caption. Can be empty. |
media[].position | string | top, end, or h:N: after the article's heading number N, counting H2 and H3 headings from 0. |
media[].afterHeading | string or null | The text of the heading the item goes after, when it goes after one. |
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:
{ "id": "1234", "url": "https://blog.example.com/cold-brew-coffee" }id(optional): SerpPal stores it and sends it aspost.idin laterupdateevents.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 answer | What happens |
|---|---|
| 2xx | The post is marked Published (or published as draft). |
| 4xx, for example 400, 401 or 422 | A 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 answer | A 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)
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
$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)
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']}")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
testevent is the sample Zapier needs. - Add a filter step that continues only when
eventispublish(andupdate, 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
updateeventspost.idis Zapier's id, not your platform's. Match updates onpost.sluginstead. - 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
eventso 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.