publish where your site already lives
Connect Webhook to PilotDeck
Send each approved article as a signed JSON POST to your own endpoint, verify the HMAC signature and test the link. Also the route for Framer.
What connecting a webhook does#
A CMS connection is the last hop of the PilotDeck pipeline: once you approve a draft, PilotDeck delivers it for you. With Webhook, the destination is a URL you own. PilotDeck sends the article as a signed JSON POST, and your side decides how to store and render it. The example below uses a PilotDeck site named pilotdeck-testwebsite.
Works with any stack
Headless sites, automations, serverless functions and custom back ends can all receive the same payload. No platform API is involved.
Signed deliveries
Every request carries an HMAC-SHA256 signature of the raw body, so your endpoint can prove the request came from PilotDeck.
SEO included
The payload carries the meta title, meta description, FAQ and ready-made JSON-LD, so you can place them in your page head.
Before you start#
| You need | Details |
|---|---|
| An endpoint you control | An HTTPS URL that accepts POST requests with a JSON body, such as https://example.com/hooks/pilotdeck. It must answer with a 2xx status. |
| Code to receive the post | A handler that verifies the signature, then creates or updates the article in your system, keyed by post.slug or post.id. |
| A PilotDeck login | Permission to change integrations for the site. Without it the Connect button is unavailable. |
Overview#
- 1Build or choose an HTTPS endpoint that accepts a JSON POSTYou
- 2Open Settings > Integrations in PilotDeck and pick WebhookYou
- 3Enter the Webhook URL and choose a publish modeYou
- 4Connect. PilotDeck sends a signed ping and creates the signing secretPilotDeck
- 5Save the signing secret in your receiverYou
- 6Send a test draft and check your endpoint received itVerify
Create a route that accepts POST with a JSON body and returns a 2xx status. Keep the raw request body available, because the signature is computed over the exact bytes PilotDeck sends.
While you are connecting, handle the ping event first (see The ping event). It is sent before you have seen the signing secret, so your endpoint should answer it with 200 and leave signature checks for post.publish deliveries.
Optionally, answer a post.publish request with a JSON body containing id (or remoteId) and url (or remoteUrl). PilotDeck stores them as the remote ID and link of the delivered article. An empty or non-JSON body is fine.
In PilotDeck, make sure the correct site is selected in the top-left site switcher (here, pilotdeck-testwebsite). Go to Settings > Integrations and find the CMS Connection card. With nothing connected it reads "No CMS connected".
Click Webhook (Signed JSON POST to any endpoint). A "How to connect Webhook" panel opens next to the form. It lists the setup steps, the sample payload under What we POST to your endpoint, and the Request headers.
- Type your endpoint into Webhook URL (required), for example
https://example.com/hooks/pilotdeck. Includehttps://and the full domain. - Pick a Publish mode. See Publish mode for what it means for a webhook.
- There is no Category field for Webhook. The category is not sent.
Click Test connection first. PilotDeck sends a signed ping request to your URL and saves nothing. You should see "Looks good, credentials work." If the endpoint does not answer with a 2xx status, you see an error instead.
Then click Connect. PilotDeck sends one more ping and only saves the connection if your endpoint accepts it. If it does not, the connection is not created and you see "That endpoint did not accept the test delivery" with the HTTP status your endpoint returned. On success a Save this signing secret, it won't be shown again box appears with the secret, and the card shows an ACTIVE badge.
The secret is a string that starts with the prefix whsec_ followed by random hex characters. Store it as <your-signing-secret> in your receiver's environment.
On the connected card click Send test draft. PilotDeck sends a bundled sample article to your endpoint as a post.publish event. The card shows "Test draft created." when your endpoint answers with a 2xx status, and a link if your response included url or remoteUrl. Check that your system stored the article.
What PilotDeck sends#
Each article is one POST with Content-Type: application/json and the user agent Pilotdeck-Connector/1.0. The body is a JSON envelope. Values below are placeholders. Optional author fields are left out when empty.
{
"event": "post.publish",
"deliveryId": "<unique-delivery-id>",
"post": {
"id": "<post-id>",
"title": "Example article title",
"slug": "example-article-title",
"html": "<h2>Example heading</h2><p>Article body as HTML.</p>",
"metaTitle": "Example article title | Example",
"metaDescription": "A short description for search results.",
"tags": [],
"categories": [],
"faq": [
{ "question": "Example question?", "answer": "Example answer." }
],
"conclusion": "",
"author": {
"name": "Example Author",
"title": "Example role",
"bio": "Short bio.",
"url": "https://example.com/authors/example",
"image": "https://example.com/authors/example.png",
"slug": "example"
},
"featuredImage": "https://example.com/images/hero.png",
"ogImage": "https://example.com/images/hero.png",
"images": [],
"status": "draft",
"publishedAt": null,
"updatedAt": "2026-01-01T00:00:00.000Z",
"canonical": null
},
"jsonLd": [
{ "@context": "https://schema.org", "@type": "FAQPage" },
{ "@context": "https://schema.org", "@type": "BlogPosting" }
],
"sentAt": "2026-01-01T00:00:00.000Z"
}The jsonLd entries are shortened above. In a real delivery they are complete schema.org objects: an FAQPage when the article has FAQ entries, and a BlogPosting that includes the author when there is one.
| Field | Meaning |
|---|---|
event | post.publish for an article, ping for the connection test. |
deliveryId | A unique ID per delivery. Use it to make your handler idempotent. |
post.html | The article body as HTML. |
post.slug, post.id | Stable keys to create or update the article on your side. |
post.metaTitle, post.metaDescription, post.canonical | Map these into your page head. canonical is null unless a value exists. |
post.faq | Structured question and answer pairs. Render them as a visible FAQ section. |
post.author | The full author object, present when the site has an author. Your receiver renders it. |
post.featuredImage, post.ogImage | Image URLs. PilotDeck does not upload media, so rehost them if you need to. |
jsonLd | Ready-made structured data. Embed each entry in its own <script type="application/ld+json"> tag. |
sentAt | When the request was created, as an ISO timestamp. |
tags, categories, faq and conclusion can be empty, because they only carry content when the draft has it. Build your receiver to accept empty values for any optional field.Request headers and signature#
| Header | Value |
|---|---|
X-Pilotdeck-Event | The event type: post.publish, or ping for the connection test. |
X-Pilotdeck-Delivery | A unique delivery ID, the same value as deliveryId in the body. |
X-Pilotdeck-Signature | sha256= followed by the lowercase hex HMAC-SHA256 of the raw request body, keyed with your signing secret. |
To verify a request, compute the HMAC-SHA256 of the exact raw body using your signing secret, prefix it with sha256=, and compare it to the header with a constant-time comparison. Verify the raw bytes before parsing the JSON, because re-serialising the body can change it.
const crypto = require("crypto");
function isValidSignature(rawBody, header, secret) {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// rawBody: the unparsed request body
// header: the X-Pilotdeck-Signature value
// secret: process.env.PILOTDECK_SIGNING_SECRET (<your-signing-secret>)post.publish request with a missing or wrong signature using 401. Do not store its content.The ping event#
Test connection and Connect both send a ping. It has the same envelope as an article, with event set to ping, post set to null and jsonLd as an empty list. It is signed like any other request.
PilotDeck creates the signing secret for each attempt, and you only see the final one after Connect succeeds. So the ping signature cannot be checked against a secret you already hold. Answer ping with a 2xx status without rejecting it on signature, then enforce signatures on post.publish once you have saved the secret.
Timeouts and failures#
| Situation | What PilotDeck does |
|---|---|
Your endpoint answers 2xx | The delivery counts as successful. The card stays ACTIVE. |
| Your endpoint is slow | PilotDeck waits up to 20 seconds for each request, then treats it as a network failure. A whole deploy job is capped at 2 minutes. |
Your endpoint answers 400 or 422 | Treated as a validation failure. The delivery is marked failed. |
Your endpoint answers 401 | Treated as an authentication failure. The connection is marked needs_reauth. |
Your endpoint answers 403, 404 or 413 | Treated as permission denied, not found or payload too large. The connection is marked error. |
Your endpoint answers 429 or 5xx, or cannot be reached | Treated as a retryable failure (rate limited, server error or network). The connection is marked error. |
| The URL is not valid | The form tells you to include https:// and the full domain. |
Each delivery is a single attempt. The webhook adapter does not retry on its own, so make your endpoint fast, acknowledge quickly, and do heavy work after answering. Because a delivery can be sent more than once if a deploy is run again, treat deliveryId and post.slug as keys and make your handler idempotent.
Publish mode#
| Mode | What it is |
|---|---|
| Draft only (default) | The CMS setting that tells a connector to create items as drafts instead of publishing them. |
| Publish live | The CMS setting that tells a connector to publish items straight away. |
The webhook connector does not read this setting. It cannot create a draft or schedule anything on your side, because it only delivers JSON. A delivery looks the same in either mode, and what happens next is decided by your receiver. If you want a review step, hold the article in your own system until someone approves it.
Disconnecting#
On the connected card click Disconnect and confirm. Approved content stops being delivered right away. To reconnect, repeat steps 2 to 4. You receive a new signing secret, so update your receiver with it. Remove the old secret from your receiver when you disconnect.
Troubleshooting#
"Test connection" fails
Check that the URL starts with https://, is reachable from the public internet and answers POST with a 2xx status. A 401 or 403 from your endpoint on the ping usually means it is rejecting the request on signature. Let ping through, as described in The ping event.
Connect says the endpoint did not accept the test delivery
Your endpoint returned a non-2xx status. The message includes the HTTP status. Fix the handler, then click Connect again.
The signature never matches
Compute the HMAC over the raw body bytes, not over re-serialised JSON. Use the full sha256= prefix when comparing, and make sure you saved the secret from the box shown after Connect.
I lost the signing secret
It is shown only once. Disconnect, connect again to get a new secret, then update your receiver.
The same article arrives twice
Deliveries are single attempts, but a deploy can be run again. Use deliveryId or post.slug as an idempotency key and update instead of creating a duplicate.
How do I use this with Framer?
Pick the Framer tile, which opens this Webhook form. Point Webhook URL at a service you run that receives the POST and writes the article into your Framer project.
There is no Category field
That is expected. Webhook deliveries do not carry a category setting, so categories arrives empty. Your receiver decides where the article goes.