Guide
Using the Inksong API in a CMS pipeline
Step-by-step integration with WordPress, Sanity, and Strapi. Three real code paths from CMS draft to humanized draft.
title: "Using the Inksong API in a CMS pipeline" description: "Step-by-step integration with WordPress, Sanity, and Strapi. Three real code paths from CMS draft to humanized draft." date: "2026-05-13" tag: "Guide" author: "Inksong"
The Inksong dashboard works fine for one document at a time. But if you're a marketing team with a real draft pipeline — content lives in a CMS, goes through review, ships to production — clicking through the dashboard breaks the flow. The API lets you integrate humanization directly into that pipeline: draft in your CMS, trigger humanization on save or on a manual flag, write the result back, keep going.
This guide shows three integration patterns for the three CMSes we get asked about most: WordPress (PHP, server-side hook), Sanity (Node.js, webhook-driven), and Strapi (Node.js, lifecycle hook). The patterns translate cleanly to other CMSes; pick the closest shape.
The endpoints
A quick recap of what you'll be hitting.
POST /api/v1/documents/upload— multipart upload with the file plus tone, domain, humanness, and optional voice profile ID. Returns ajob_id.GET /api/v1/documents/{job_id}— status endpoint. Returnspending,processing,completed, orfailed.GET /api/v1/documents/{job_id}/download— once status iscompleted, returns the humanized file as a binary stream.
Auth is via the X-API-Key header. Keys are prefixed ink_… and you generate them from Dashboard → API Keys. Base URL is https://api.inksong.app.
Rate limits to design around: 20 uploads per hour per user, 60 reads per minute, 10 auth attempts per minute per IP. The read budget is generous; poll every 2 seconds without worry. The upload budget is the one to watch for batch ingestion — back off or queue if you're feeding many documents through at once.
Pattern A — WordPress (PHP)
The cleanest hook on WordPress is a custom meta box on the post edit screen with a "Humanize this draft" button. On click, the handler reads the post content, sends it as a TXT payload (or DOCX if you've stored a Word source), polls until done, and replaces the post content with the humanized version.
<?php
// inksong-humanize.php
// Add as a plugin or drop into your theme's functions.php.
define('INKSONG_API_KEY', getenv('INKSONG_API_KEY')); // never hardcode
define('INKSONG_BASE', 'https://api.inksong.app');
add_action('add_meta_boxes', function () {
add_meta_box('inksong_humanize', 'Inksong', 'inksong_meta_box', 'post', 'side');
});
function inksong_meta_box($post) {
$nonce = wp_create_nonce('inksong_humanize_' . $post->ID);
echo '<button type="button" class="button button-primary"
onclick="inksongHumanize(' . $post->ID . ', \'' . $nonce . '\')">
Humanize this draft
</button>';
}
add_action('wp_ajax_inksong_humanize', function () {
$post_id = intval($_POST['post_id']);
check_ajax_referer('inksong_humanize_' . $post_id, 'nonce');
$post = get_post($post_id);
$content = wp_strip_all_tags($post->post_content);
// Upload
$boundary = wp_generate_password(24, false);
$body = "--$boundary\r\n";
$body .= "Content-Disposition: form-data; name=\"file\"; filename=\"draft.txt\"\r\n";
$body .= "Content-Type: text/plain\r\n\r\n$content\r\n";
foreach (['tone' => 'balanced', 'domain' => 'marketing', 'humanness' => '60'] as $k => $v) {
$body .= "--$boundary\r\nContent-Disposition: form-data; name=\"$k\"\r\n\r\n$v\r\n";
}
$body .= "--$boundary--\r\n";
$upload = wp_remote_post(INKSONG_BASE . '/api/v1/documents/upload', [
'headers' => [
'X-API-Key' => INKSONG_API_KEY,
'Content-Type' => "multipart/form-data; boundary=$boundary",
],
'body' => $body,
'timeout' => 30,
]);
$code = wp_remote_retrieve_response_code($upload);
if ($code === 429) wp_send_json_error('Rate limited; try again in an hour.', 429);
if ($code !== 200) wp_send_json_error('Upload failed: ' . $code, $code);
$job_id = json_decode(wp_remote_retrieve_body($upload), true)['job_id'];
// Poll
for ($i = 0; $i < 60; $i++) {
sleep(2);
$status = wp_remote_get(INKSONG_BASE . "/api/v1/documents/$job_id", [
'headers' => ['X-API-Key' => INKSONG_API_KEY],
]);
$state = json_decode(wp_remote_retrieve_body($status), true)['status'];
if ($state === 'completed') break;
if ($state === 'failed') wp_send_json_error('Humanization job failed.');
}
// Download
$download = wp_remote_get(INKSONG_BASE . "/api/v1/documents/$job_id/download", [
'headers' => ['X-API-Key' => INKSONG_API_KEY],
]);
$humanized = wp_remote_retrieve_body($download);
wp_update_post(['ID' => $post_id, 'post_content' => $humanized]);
wp_send_json_success(['updated' => true]);
});For long content, the AJAX handler will exceed PHP's default execution time. In production, push the polling step into a WP-Cron event or a background queue (Action Scheduler works well). Don't make the editor wait 60 seconds on a synchronous request.
Pattern B — Sanity (Node.js, webhook-driven)
Sanity's webhook system fires on publish (or any GROQ-filtered change). The handler — a Next.js Route Handler, a standalone Express service, or a serverless function — receives the document, pipes the body content through Inksong, and writes the humanized version back via Sanity's management API.
// app/api/sanity-humanize/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@sanity/client';
const sanity = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: process.env.SANITY_DATASET!,
token: process.env.SANITY_WRITE_TOKEN!,
useCdn: false,
apiVersion: '2025-01-01',
});
const INKSONG = 'https://api.inksong.app';
const KEY = process.env.INKSONG_API_KEY!;
export async function POST(req: NextRequest) {
const doc = await req.json();
if (!doc.body || doc.humanized === true) return NextResponse.json({ skipped: true });
// 1. Upload as Markdown
const form = new FormData();
form.append('file', new Blob([doc.body], { type: 'text/markdown' }), 'draft.md');
form.append('tone', 'balanced');
form.append('domain', 'marketing');
form.append('humanness', '60');
if (doc.voiceProfileId) form.append('voice_profile_id', doc.voiceProfileId);
const up = await fetch(`${INKSONG}/api/v1/documents/upload`, {
method: 'POST',
headers: { 'X-API-Key': KEY },
body: form,
});
if (up.status === 429) return NextResponse.json({ error: 'rate_limited' }, { status: 429 });
if (!up.ok) return NextResponse.json({ error: 'upload_failed' }, { status: 502 });
const { job_id } = await up.json();
// 2. Poll
for (let i = 0; i < 90; i++) {
await new Promise((r) => setTimeout(r, 2000));
const s = await fetch(`${INKSONG}/api/v1/documents/${job_id}`, {
headers: { 'X-API-Key': KEY },
}).then((r) => r.json());
if (s.status === 'completed') break;
if (s.status === 'failed') return NextResponse.json({ error: 'job_failed' }, { status: 502 });
}
// 3. Download and patch
const dl = await fetch(`${INKSONG}/api/v1/documents/${job_id}/download`, {
headers: { 'X-API-Key': KEY },
});
const humanized = await dl.text();
await sanity
.patch(doc._id)
.set({ body: humanized, humanized: true })
.commit();
return NextResponse.json({ ok: true });
}The humanized: true guard prevents the webhook from re-triggering on its own write. Without it, you'll loop until the rate limit cuts you off.
For documents large enough that polling exceeds your function timeout (Vercel's hobby tier is 10s, pro is 60s, fluid is longer), don't poll inline. Save the job_id to a queue or a database row, run a cron every minute to check status, and patch when complete.
Pattern C — Strapi (Node.js plugin via lifecycle hook)
Strapi lifecycles fire on beforeCreate, afterCreate, beforeUpdate, afterUpdate. The cleanest pattern is a custom humanize boolean field on your content type; when an author sets it true and saves, the lifecycle intercepts, sends the content through Inksong, and writes the result back before the document hits the database.
// src/api/article/content-types/article/lifecycles.ts
const INKSONG = 'https://api.inksong.app';
const KEY = process.env.INKSONG_API_KEY!;
async function humanize(markdown: string): Promise<string> {
const form = new FormData();
form.append('file', new Blob([markdown], { type: 'text/markdown' }), 'draft.md');
form.append('tone', 'balanced');
form.append('domain', 'marketing');
form.append('humanness', '60');
const up = await fetch(`${INKSONG}/api/v1/documents/upload`, {
method: 'POST',
headers: { 'X-API-Key': KEY },
body: form,
});
if (up.status === 429) throw new Error('Inksong rate limited');
if (!up.ok) throw new Error(`Inksong upload failed: ${up.status}`);
const { job_id } = await up.json();
for (let i = 0; i < 90; i++) {
await new Promise((r) => setTimeout(r, 2000));
const s = await fetch(`${INKSONG}/api/v1/documents/${job_id}`, {
headers: { 'X-API-Key': KEY },
}).then((r) => r.json());
if (s.status === 'completed') {
const dl = await fetch(`${INKSONG}/api/v1/documents/${job_id}/download`, {
headers: { 'X-API-Key': KEY },
});
return await dl.text();
}
if (s.status === 'failed') throw new Error('Inksong job failed');
}
throw new Error('Inksong polling timed out');
}
export default {
async beforeUpdate(event) {
const { data } = event.params;
if (data.humanize === true && data.body) {
try {
data.body = await humanize(data.body);
data.humanize = false; // clear the flag so we don't re-run
data.humanizedAt = new Date();
} catch (err) {
strapi.log.error('Humanization failed', err);
// Don't block the save; just log.
}
}
},
};This blocks the save until humanization completes, which is fine for documents that finish in 25–60 seconds but bad for anything longer. For larger content, do the lifecycle work in afterUpdate, queue the job, and patch via Strapi's entity service when the result lands.
Common integration issues
A few things to design around:
- Long-running jobs vs. CMS request timeouts. Most CMS request handlers expect to return in seconds, not minutes. For documents over ~3,000 words, don't poll inline — store the
job_id, run a separate worker (cron job, queue consumer, scheduled task) to check status, and patch when complete. Webhook-driven CMSes make this easier; lifecycle-driven ones need a bit more plumbing. - Rate limits. 20 uploads per hour per user is plenty for a single editor but tight for batch ingestion. If you're feeding hundreds of documents through, queue uploads with a 3-minute floor between them, and watch for 429s. Back off exponentially on 429.
- API key storage. Never put the key in client-side code. Always server-side environment variables, secrets manager, or vault. The key has full write access to your account; treat it like a database password.
- Idempotency. Always set a flag (
humanized: true,humanizedAt: timestamp, or similar) on documents after a successful run, and check it on entry. Webhook and lifecycle systems re-fire on writes; without a guard, you'll humanize the humanized output.
Wrap
API reference for full endpoint details, request/response shapes, and error codes.
Start humanizing today
5 documents free a month, no card needed. Three minutes to your first humanized doc.
- 5 documents/month on the free tier
- No credit card required
- Cancel or upgrade anytime