Custom Blog Integration
PostClaw lets you publish blog posts to a server you control. Instead of connecting to Medium, Dev.to, or Hashnode, you host your own blog and PostClaw POSTs to your endpoints. This guide explains the contract your server must implement, the auth model, HMAC verification, and idempotency semantics.
Requirement: Business tier or above.
Overview
The custom blog publisher is built on three endpoints your server implements:
POST {base}/posts → creates a new post (201)
PATCH {base}/posts/{id} → updates an existing post (200)
GET {base}/health → identifies your server as a PostClaw custom blog endpoint (200)
All requests are authenticated with a Bearer token you provide in PostClaw settings. Mutating requests (POST and PATCH) include optional HMAC signatures for integrity verification.
Endpoints
POST /posts
Creates a new blog post.
Request:
POST https://your-blog.com/posts HTTP/1.1
Authorization: Bearer YOUR_TOKEN
Idempotency-Key: postclaw_<uuid>
X-PostClaw-Signature: sha256=<hmac>
Content-Type: application/json
{
"title": "AI is reshaping social media",
"content_html": "<p>Post content as HTML...</p>",
"external_id": "postclaw_12345678-abcd...",
"excerpt": "A brief summary",
"slug": "ai-social-media",
"tags": ["AI", "social media"],
"categories": ["technology", "news"],
"cover_image_url": "https://cdn.example.com/img.jpg",
"canonical_url": "https://original-source.com/article",
"status": "published",
"published_at": "2026-04-15T09:00:00Z"
}
Response (201 Created):
{
"id": "post_abc123",
"url": "https://your-blog.com/posts/ai-social-media",
"status": "published"
}
Required in response: id (string). PostClaw cannot edit a post later without a post ID.
Optional in response: url (string), status (string). If omitted, PostClaw derives a URL from the post ID.
PATCH /posts/{id}
Updates an existing post (e.g., to republish with new content).
Request:
PATCH https://your-blog.com/posts/post_abc123 HTTP/1.1
Authorization: Bearer YOUR_TOKEN
Idempotency-Key: postclaw_12345678-abcd....u.a1b2c3d4e5f6
X-PostClaw-Signature: sha256=<hmac>
Content-Type: application/json
{
"title": "Updated title",
"content_html": "<p>Updated content...</p>",
"external_id": "postclaw_12345678-abcd...",
"status": "published",
"published_at": "2026-04-15T10:00:00Z"
}
Note the Idempotency-Key header is not the same as external_id on a PATCH — see Idempotency for why they diverge on update.
Response (200 OK):
{
"id": "post_abc123",
"url": "https://your-blog.com/posts/ai-social-media",
"status": "published"
}
A bare 200 with an empty body is also valid — PostClaw falls back to the known post ID and derives the URL.
GET /health
Verifies your server is reachable, the token is valid, and — critically — that it actually implements this contract (used at connect time).
Request:
GET https://your-blog.com/health HTTP/1.1
Authorization: Bearer YOUR_TOKEN
Response (200 OK):
{
"service": "postclaw-custom-blog",
"version": 1
}
Required in response: a service field whose value is exactly postclaw-custom-blog. This is checked strictly (extra whitespace around the value is tolerated, but the value itself must match exactly). Extra fields are ignored, so feel free to add your own (uptime, region, build info, whatever you find useful) — they’re never validated.
A bare 200 is not sufficient on its own. PostClaw used to accept any sub-400 status here without inspecting the body, which meant a typo’d domain, a parked page, a CDN error page, or literally any other reachable HTTP service could return a green “connected” status without implementing a single byte of the actual POST/PATCH contract — the mistake was only discovered on the first real publish attempt. Requiring your server to positively identify itself closes that gap: an HTML response, an empty body, JSON without the service field, or the wrong value are all refused at connect time with a message telling you exactly what to return.
Authentication
Every request includes an Authorization header with a Bearer token:
Authorization: Bearer YOUR_BEARER_TOKEN
Your server MUST verify this token on every request. Reject (401 Unauthorized) if the token is missing or incorrect.
Token Validation (Pseudocode)
const token = req.headers.authorization?.replace(/^Bearer\s+/, '');
if (!token || token !== expectedToken) {
return res.status(401).json({ error: 'Unauthorized' });
}
Idempotency
PostClaw sends an Idempotency-Key header on every POST and PATCH request. The header value is scoped per (method, content) — it is NOT the same value on every request for a given post. Treat it exactly like a Stripe idempotency key: same key + a retry -> replay the cached response; a repeat key with a different body is either a bug on our end or a signal you should reject, never something to silently apply as if it were the new content.
POST /posts
Idempotency-Key: postclaw_<post_uuid>
Stable across a retry of the same create (network retry, PostClaw restart) — always exactly this value for a given post, regardless of how many times a create is retried.
PATCH /posts/{id}
Idempotency-Key: postclaw_<post_uuid>.u.<12-hex sha256 of the request body>
Scoped to the edit, not just the post: a retry of the identical edit reproduces the identical key (same bytes hash the same), but a genuinely different edit — even to the same post — gets a different key. This is deliberate: if PostClaw reused the bare postclaw_<post_uuid> key for every PATCH the way it does for POST, a spec-compliant idempotency store would either replay the first edit’s cached response for every later edit (PostClaw would record the update as successful while your live post never actually changes — silent data loss) or reject every edit after the first outright as a key conflict, depending on how strictly you implement the pattern below. Either failure mode is a bug in our header, not something your server needs to work around — that’s why the format changed.
The body’s external_id field is unaffected
external_id in the request body is always the bare, method-independent value:
{
"external_id": "postclaw_<post_uuid>",
...
}
This is stable resource identity — use it to find/upsert the right post — and never changes with content, unlike the header. Do not conflate the two: external_id answers “which post is this,” Idempotency-Key answers “have I already processed this exact request.”
Your server MUST treat the header as an idempotency key scoped to its own value: if a request with the same Idempotency-Key arrives again, return the same response without repeating the side effect (create a duplicate post / apply the edit twice).
Implementation strategy:
- Store the
Idempotency-Keyheader value (notexternal_id) as the idempotency lookup key for a given request attempt — a simpleMap<idempotency_key, cached_response>works for both POST and PATCH. - On POST, additionally index by
external_idso you can find/upsert the right post across genuinely separate requests (create retry vs. a later update targeting the same post). - On PATCH, check the
Idempotency-Key:- Seen before with the same key: return the cached response — this is a retry of the same edit.
- Not seen before: this is either the first attempt at this edit or a genuinely new edit (different content) — apply it, cache the response under this key.
HMAC Signature Verification
PostClaw optionally signs requests with an X-PostClaw-Signature header. Use it to verify the request was not tampered with in transit.
Header Format
X-PostClaw-Signature: sha256=<hex>
The signature is HMAC-SHA256 computed over the raw request body bytes (not re-serialized JSON) using your bearer token as the secret key.
The Signing Key Is Your Bearer Token — Read This Before Logging Signatures
There is no separate webhook secret for custom blog requests: the HMAC key is the same bearer token you use for Authorization. This is a deliberate simplification — one credential to provision and rotate instead of two — but it has a real consequence for how you handle the signature on your end:
- A logged signature is an offline brute-force oracle against your token. If an attacker obtains
X-PostClaw-Signaturevalues together with their corresponding raw request bodies (e.g. from an access log, an APM tool that captures headers, or a support ticket with a raw request dump), they can attempt to brute-force the bearer token offline by trying candidate tokens againstHMAC-SHA256(candidate, body)until one produces the observed signature — without ever calling your server or PostClaw’s again. This is generally impractical against a high-entropy, randomly generated token, but it becomes real risk if your token is short, guessable, or reused elsewhere. - Never log the
X-PostClaw-Signatureheader value, and avoid any request-capture tooling (proxies, APM, error trackers) that would persist it alongside the body. Log that a signature was present and whether it verified — not the value itself. - If you suspect a signature was exposed (a log leak, a shared debugging session, a support ticket that included raw headers), rotate your bearer token in PostClaw settings. Rotating invalidates the old token immediately for both authentication and signing — there is nothing else to rotate separately.
Verification (Node.js Example)
import crypto from 'crypto';
const token = req.headers.authorization?.replace(/^Bearer\s+/, '');
const signature = req.headers['x-postclaw-signature'];
if (signature) {
// Compute HMAC-SHA256 over raw body bytes with the token as key
const bodyBytes = req.rawBody; // Express: use express.raw() middleware
const computed = 'sha256=' + crypto
.createHmac('sha256', token)
.update(bodyBytes)
.digest('hex');
// Use timing-safe comparison to prevent timing attacks. timingSafeEqual
// requires Buffer/TypedArray arguments (it throws on plain strings), and
// it throws a RangeError — not a boolean false — on mismatched buffer
// lengths. Convert to Buffers and check the lengths match first, so a
// malformed or wrong-length signature returns a clean 401 instead of
// crashing your handler with a 500.
const computedBuf = Buffer.from(computed);
const signatureBuf = Buffer.from(signature);
const validSignature =
computedBuf.length === signatureBuf.length && crypto.timingSafeEqual(computedBuf, signatureBuf);
if (!validSignature) {
return res.status(401).json({ error: 'Signature verification failed' });
}
}
Critical: Verify over req.rawBody (the exact bytes sent over the wire), not re-serialized JSON. If you parse JSON first, you may compute a different hash due to whitespace or key ordering differences.
Signing is Optional
If the signature header is missing or empty, PostClaw sent it unsigned. You may accept unsigned requests if you trust the transport layer (e.g., HTTPS with certificate pinning), or reject them (401) if you require signatures. The choice is yours.
Request Body Fields
| Field | Type | Nullable | Description |
|---|---|---|---|
title | string | No | Post title (required). Falls back to AI-generated title if omitted in composer. |
content_html | string | No | Post body as HTML (required, already sanitized by PostClaw). |
external_id | string | No | Idempotency key (same as Idempotency-Key header). Use this to dedupe. |
excerpt | string | Yes | Short summary of the post. |
slug | string | Yes | URL slug (e.g., ai-social-media). Server may ignore and generate its own. |
tags | array of strings | Yes | List of tags. |
categories | array of strings | Yes | List of categories. |
cover_image_url | string | Yes | URL of the featured/cover image. PostClaw falls back to the first attached media if omitted. |
canonical_url | string | Yes | Canonical URL (cross-post link). Informs SEO that this post syndicates from elsewhere. |
status | string | No | One of "draft" or "published" (default). Draft posts appear only to authenticated users. |
published_at | string | Yes | ISO 8601 timestamp (e.g., 2026-04-15T09:00:00Z). Server may use for scheduled publishing. |
Expected Status Codes and Error Behavior
Success Responses
| Code | Meaning |
|---|---|
201 Created | POST /posts succeeded. Response must include {"id":"..."}. |
200 OK | PATCH /posts/{id} succeeded, or GET /health succeeded and returned the required {"service":"postclaw-custom-blog",...} body — a 200 with the wrong or missing body is treated as a connect-time failure, not a success. |
Error Responses
| Code | Action | Example |
|---|---|---|
401 Unauthorized / 403 Forbidden | Treated as an authentication problem. The post is marked failed and the channel is flagged as needing reconnection. Not retried — a broken token will not fix itself. | Bearer token does not match the stored token; account suspended. |
429 Too Many Requests | Treated as rate limiting and is retried later. | Your server is throttling PostClaw. |
5xx Server Error | Treated as a transient network failure and is retried later. | Database temporarily unavailable. |
Other 4xx (400, 404, 408, 422, …) | Treated as a client error: the request itself is wrong, so retrying it unchanged would fail identically. The post is marked failed with no retry. | Missing title or content_html; PATCH against a deleted post. |
| Network / TLS / timeout error | Treated as transient and retried later. | Your server is unreachable or too slow. |
redirect (3xx) | PostClaw follows redirects only to public IPs over HTTPS. Redirects to private ranges (10.x, 192.168.x, 172.16-31.x), link-local, or localhost are rejected — the guard re-runs on every hop, not just the first request. Additionally, on POST /posts and PATCH /posts/{id} (which carry Idempotency-Key and, optionally, X-PostClaw-Signature), a redirect to a different host — public or not — is rejected outright, even to another HTTPS domain you also control. This is intentional: those headers are never stripped on a cross-host hop the way Authorization is, and for a signed request the signature’s HMAC key is your own bearer token, so a redirect target that isn’t your server would receive a live, brute-forceable credential artifact. GET /health carries neither header and is unaffected. | https://old-domain.com → https://new-domain.com is allowed only for GET /health. A POST or PATCH redirected to any different host — including another domain you own — is rejected. Any redirect to http://192.168.1.1 is rejected regardless of method. |
PostClaw’s Retry Logic
Only two outcomes are retried: 429 and anything transient (5xx, network, TLS, timeout). Every other 4xx is permanent.
Note 408 Request Timeout is not treated as transient — if your server is slow, return 429 or a 5xx, not 408, or PostClaw will give up on the post.
Retry scheduling is handled by PostClaw’s post scheduler, not by the publisher, so the exact interval is not part of this contract. Design your endpoint to be idempotent (see Idempotency) rather than depending on a specific retry cadence.
Endpoints Summary
URL: Your base URL (e.g., https://your-blog.com)
| Method | Path | Auth | Idempotency | Use Case |
|---|---|---|---|---|
| GET | /health | Bearer token | No | Connection validation at setup time |
| POST | /posts | Bearer token | Yes (Idempotency-Key) | Create a new post |
| PATCH | /posts/{id} | Bearer token | Yes (Idempotency-Key) | Update an existing post |
Reference Server Implementation
Here is a minimal Node.js/Express server that implements the contract correctly. Copy this and adapt for your use case.
import express from 'express';
import crypto from 'crypto';
const app = express();
const expectedToken = 'YOUR_BEARER_TOKEN'; // Store securely (env var, secrets manager, etc.)
// Middleware: capture raw body for HMAC verification
app.use(express.raw({ type: 'application/json' }));
// Middleware: verify Bearer token
const verifyToken = (req, res, next) => {
const authHeader = req.headers.authorization || '';
const token = authHeader.replace(/^Bearer\s+/, '');
if (!token || token !== expectedToken) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
};
// Middleware: verify HMAC signature (optional but recommended)
const verifySignature = (req, res, next) => {
const signature = req.headers['x-postclaw-signature'];
if (signature) {
const token = req.headers.authorization.replace(/^Bearer\s+/, '');
const computed = 'sha256=' + crypto
.createHmac('sha256', token)
.update(req.body)
.digest('hex');
// Timing-safe comparison
if (!crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(signature))) {
return res.status(401).json({ error: 'Signature verification failed' });
}
}
next();
};
// In-memory storage (replace with a database in production).
const posts = new Map(); // external_id → post record (resource identity)
const idempotencyCache = new Map(); // Idempotency-Key header → { status, body } (per-request replay cache)
// replayIfSeen returns the cached response for this Idempotency-Key and
// short-circuits the handler, or null if this key hasn't been seen before.
// IMPORTANT: this is keyed by the Idempotency-Key HEADER, not by
// external_id. The header is scoped per (method, content) — see the
// Idempotency section above — so caching by external_id instead would
// replay a CREATE's cached response for an unrelated UPDATE (or vice
// versa), which is the exact bug this reference implementation used to
// have.
function replayIfSeen(idempotencyKey, res) {
if (!idempotencyKey || !idempotencyCache.has(idempotencyKey)) return false;
const cached = idempotencyCache.get(idempotencyKey);
res.status(cached.status).json(cached.body);
return true;
}
function cacheAndSend(idempotencyKey, res, status, body) {
if (idempotencyKey) idempotencyCache.set(idempotencyKey, { status, body });
res.status(status).json(body);
}
// Health check — the "service" field is what tells PostClaw this endpoint
// actually implements the contract, not just that something answered on
// this URL. Any 2xx response WITHOUT this exact field is refused at connect
// time (see the GET /health section above for why).
app.get('/health', verifyToken, (req, res) => {
res.status(200).json({ service: 'postclaw-custom-blog', version: 1 });
});
// Create post
app.post('/posts', verifyToken, verifySignature, (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
if (replayIfSeen(idempotencyKey, res)) return; // retry of the same create
let payload;
try {
payload = JSON.parse(req.body.toString());
} catch {
return res.status(400).json({ error: 'Invalid JSON' });
}
const { title, content_html, external_id } = payload;
if (!title || !content_html || !external_id) {
return res.status(400).json({ error: 'Missing required fields' });
}
// A post with this external_id already exists but we haven't seen this
// exact Idempotency-Key (e.g. our idempotencyCache was cleared by a
// restart) — resource identity still wins, so return the existing post
// rather than creating a duplicate.
if (posts.has(external_id)) {
const existing = posts.get(external_id);
return cacheAndSend(idempotencyKey, res, 201, { id: existing.id, url: existing.url, status: existing.status });
}
const postId = 'post_' + Date.now();
const post = {
id: postId,
url: `https://your-blog.com/posts/${postId}`,
status: payload.status || 'published',
title,
content_html,
external_id,
created_at: new Date().toISOString()
};
posts.set(external_id, post);
cacheAndSend(idempotencyKey, res, 201, { id: post.id, url: post.url, status: post.status });
});
// Update post
app.patch('/posts/:id', verifyToken, verifySignature, (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
if (replayIfSeen(idempotencyKey, res)) return; // retry of the same edit — NOT a different edit, see below
let payload;
try {
payload = JSON.parse(req.body.toString());
} catch {
return res.status(400).json({ error: 'Invalid JSON' });
}
const { id } = req.params;
// Find post by ID (naive search; use a database index in production).
let post = Array.from(posts.values()).find(p => p.id === id);
if (!post) {
return res.status(404).json({ error: 'Post not found' });
}
// No external_id/conflict special-casing needed here: because the
// Idempotency-Key is content-scoped on PATCH (postclaw_<uuid>.u.<hash>),
// a DIFFERENT edit to the SAME post never collides with a prior edit's
// key — replayIfSeen above only short-circuits a genuine retry of this
// exact edit, so we always fall through to applying a new one here.
if (payload.title) post.title = payload.title;
if (payload.content_html) post.content_html = payload.content_html;
if (payload.status) post.status = payload.status;
if (payload.published_at) post.published_at = payload.published_at;
post.updated_at = new Date().toISOString();
cacheAndSend(idempotencyKey, res, 200, { id: post.id, url: post.url, status: post.status });
});
app.listen(3000, () => console.log('Server running on :3000'));
Key security points in this example:
- Raw body capture:
express.raw()middleware preserves the body as bytes for HMAC verification. - Timing-safe comparison:
crypto.timingSafeEqual()prevents timing-based signature forgery. - Idempotency cache keyed by the header, not
external_id:replayIfSeen/cacheAndSendkey onIdempotency-Key, which is scoped per (method, content) — see Idempotency. Caching byexternal_idinstead would make every edit after the first either replay the first edit’s response or get rejected, sinceexternal_idnever changes across edits to the same post. - Token validation: Every request requires a valid Bearer token; missing or incorrect tokens are rejected (401).
- HTTPS enforcement: In production, serve over HTTPS only; never use HTTP.
Connecting in PostClaw
- Go to Settings → Accounts → Add Channel.
- Select Your Website (Custom).
- Enter your server’s base URL (e.g.,
https://your-blog.com). - Enter the bearer token you generated on your server.
- Click Connect — PostClaw calls GET /health to validate the token and confirm your server implements this contract.
- Once connected, you can select this channel in the blog composer.
Limitations
- One blog per host per user. PostClaw identifies custom blog accounts by normalized hostname. If you reconnect with a different token for the same host, the old connection is overwritten.
- No comment posting. The contract defines only create/update/health — there is no endpoint for comments.
- Token rotation. The bearer token does not have a built-in refresh flow. To change the token, reconnect the channel in PostClaw settings with the new token.
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
| ”Connection failed” | GET /health returned non-2xx, timed out, or its body doesn’t identify itself | Verify server is reachable, token is correct, and GET /health returns 2xx with {"service":"postclaw-custom-blog","version":1} in the body. |
| Post publishes but URL is wrong | PATCH response omitted url field | Return url in response, or PostClaw derives it from post ID. |
| Post not created (status: “failed”) | 400 Bad Request, invalid JSON or missing field | Check request body matches schema; ensure all required fields are present. |
| Posts published to wrong blog | Multiple custom_blog accounts with same host | PostClaw overwrites; reconnect with correct token if needed. |
| HMAC verification fails on customer’s end | Signing over re-serialized JSON instead of raw bytes | Make sure to sign over req.rawBody (express.raw() bytes), not JSON.stringify(payload). |
Security Considerations
- HTTPS is required. Your endpoint must be HTTPS-only; HTTP endpoints are rejected at connect time.
- No internal IPs. Redirects to private IP ranges (10.x, 192.168.x, 172.16-31.x) or localhost are rejected to prevent SSRF attacks.
- No cross-host redirects on signed requests. POST /posts and PATCH /posts/{id} carry
Idempotency-Keyand, when configured,X-PostClaw-Signature— headers Go’s HTTP client does not strip on a cross-host redirect the way it stripsAuthorization. PostClaw rejects any redirect to a different host on these two endpoints, even to another public HTTPS domain, so a redirect never hands your bearer-token-derived signature to a server that isn’t yours. If you migrate domains, update the base URL in PostClaw settings directly rather than relying on a redirect. - Public DNS resolution. Your domain must resolve to a public IP address; PostClaw validates this at connect time.
- Token is a customer credential. Never log or echo the token in error messages. PostClaw treats it as a secret belonging to the user.
- The token doubles as the HMAC signing key. There is no separate webhook secret (see HMAC Signature Verification). Do not log
X-PostClaw-Signaturevalues on your server, and rotate the token if you suspect a signature was exposed alongside its request body.
Next Steps
- Blog Publishing Guide — full guide to blog publishing across all platforms.
- Settings & Accounts — manage your connected channels.