Getting started
Webhooks
Autoranko can send a signed JSON event to your server when a draft is ready for review, when an article goes live and when generation fails. No polling needed.
Set up an endpoint
- Open Dashboard → API, enter your endpoint's URL under Webhooks and click Add webhook. It receives all three events.
- Copy the signing secret (
whsec_…) right away: it's shown once. Keep it with your other secrets; if you lose it, delete the endpoint and add it again. - Deploy a handler that verifies the signature and answers with any
2xxwithin 10 seconds, then send it a test event.
Public URLs only
http or https address. localhost, loopback and private network addresses are rejected, so a webhook can't be pointed at internal services. Use HTTPS in production. To develop locally, expose your handler through a tunnel and register the tunnel's public HTTPS URL.An account can have up to 5 endpoints. Every endpoint receives events for every job on the account, whether the job was created through the API or the dashboard, immediately or on a schedule.
Events
| Event | Sent when the job enters | Extra keys in data |
|---|---|---|
| job.review | review | publish_error |
| job.published | published | wp_link |
| job.failed | failed | error |
job.review
A draft is ready for approval: generation finished and the job is waiting in review (because auto_publish was off or the site has no WordPress connection). It's also sent when a publish attempt fails and the job returns to review; data.publish_error is then true, and GET /jobs/{id} has the reason in error.
datapublish_errorbooleanOptionalOnly on a job.review sent after a failed publish attempt: alwaystrue. The reason is inerrorfrom GET /jobs/{id}.
{
"event": "job.review",
"created_at": "2026-10-25T20:13:48.902Z",
"data": {
"id": "3",
"site_id": "1",
"topic": "How to winterize a sprinkler system before the first freeze",
"target_keyword": "winterize sprinkler system",
"status": "review",
"article_title": "How to Winterize a Sprinkler System Before the First Freeze",
"meta_description": "Learn how to winterize a sprinkler system before the first freeze, with steps for shutting off water, draining lines and protecting equipment.",
"wp_post_id": null
}
}job.published
The article was published to WordPress, after POST /jobs/{id}/publish or automatically with auto_publish. data.wp_post_id is the new post's ID and data.wp_link its URL.
datawp_linkstring or nullOptionalOnly on job.published: the post URL WordPress returned, ornull.
{
"event": "job.published",
"created_at": "2026-11-02T09:04:31.226Z",
"data": {
"id": "4",
"site_id": "1",
"topic": "Drip irrigation vs. sprinklers: which saves more water?",
"target_keyword": "drip irrigation vs sprinklers",
"status": "published",
"article_title": "Drip Irrigation vs. Sprinklers: Which Saves More Water?",
"meta_description": "Compare drip irrigation and sprinklers on water use, cost and upkeep to choose the right system for your lawn and garden.",
"wp_post_id": "1542",
"wp_link": "https://demo.autoranko.com/drip-irrigation-vs-sprinklers/"
}
}job.failed
Generation failed. data.error says what went wrong. The job doesn't count toward the plan's allowance.
dataerrorstringOptionalOnly on job.failed: why generation failed.
{
"event": "job.failed",
"created_at": "2026-10-26T10:03:05.118Z",
"data": {
"id": "5",
"site_id": "1",
"topic": "How often should you water a new lawn?",
"target_keyword": "how often to water new grass",
"status": "failed",
"article_title": null,
"meta_description": null,
"wp_post_id": null,
"error": "Outline generation returned no result."
}
}Payload
Each event is a POST with a JSON body in this envelope. data is the job at the moment of the event, without the outline or article HTML: fetch GET /jobs/{id} when you need them.
application/jsoneventstringThe event name (also sent in theX-Autoranko-Eventheader).One of
job.reviewjob.publishedjob.failedcreated_atstring (date-time)When the event was sent (UTC). A retried delivery repeats the same value.dataobjectThe job at the moment of the event. Fetch GET /jobs/{id} for the outline and article HTML.datakeys(11)JobEventDataidstringJob ID: a 64-bit integer serialized as a string.site_idstringID of the site the article is for.topicstringThe topic the article was requested for.target_keywordstring or nullThe keyword the article targets, ornull.statusstringWhere the job is in its lifecycle:pending→generating→revieworpublishing→published, orfailed.One of
pendinggeneratingreviewpublishingpublishedfailedarticle_titlestring or nullTitle chosen in the outline step;nulluntil the draft is ready.meta_descriptionstring or nullSEO meta description (aimed at under 160 characters), also used as the WordPress excerpt;nulluntil the draft is ready.wp_post_idstring or nullID of the WordPress post once published (a 64-bit integer serialized as a string), otherwisenull.errorstringOptionalOnly on job.failed: why generation failed.wp_linkstring or nullOptionalOnly on job.published: the post URL WordPress returned, ornull.publish_errorbooleanOptionalOnly on a job.review sent after a failed publish attempt: alwaystrue. The reason is inerrorfrom GET /jobs/{id}.
Headers
| Header | Example | Description |
|---|---|---|
Content-Type | application/json | The body is JSON. |
User-Agent | Autoranko-Webhooks/1.0 | Identifies Autoranko's webhook sender. |
X-Autoranko-Event | job.published | The event name, the same as event in the body. |
X-Autoranko-Signature | sha256=c6c8d24a1eae8ac534944be6dffe67a2bc0ec3f43dc4abc37a00233fae2292f8 | HMAC-SHA256 of the raw body, hex-encoded and keyed with the endpoint's signing secret. |
Verify signatures
Every delivery is signed with the endpoint's secret. Check the signature before trusting anything in the body: anyone can send a POST to a public URL.
X-Autoranko-Signature: sha256=c6c8d24a1eae8ac534944be6dffe67a2bc0ec3f43dc4abc37a00233fae2292f8The value is sha256= followed by the lowercase hex HMAC-SHA256 of the raw request body, keyed with the endpoint's signing secret. To check it:
- Read the raw request body as bytes, before any JSON parsing. Re-serializing parsed JSON changes the bytes and breaks the signature.
- Compute an HMAC-SHA256 of those bytes, keyed with the endpoint's signing secret (the whole
whsec_…string, prefix included), and hex-encode it in lowercase. - Compare
sha256=plus your hex digest with theX-Autoranko-Signatureheader using a constant-time comparison. Reject the request (for example with401) if they differ. - The signature doesn't cover a timestamp. To limit replays, also reject events whose
created_atis more than a few minutes old, and skip deliveries you've already processed.
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.AUTORANKO_WEBHOOK_SECRET; // whsec_…
createServer((req, res) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
const raw = Buffer.concat(chunks); // the exact bytes, before any JSON parsing
const expected = Buffer.from("sha256=" + createHmac("sha256", SECRET).update(raw).digest("hex"));
const received = Buffer.from(req.headers["x-autoranko-signature"] ?? "");
if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
return res.writeHead(401).end();
}
const { event, data } = JSON.parse(raw);
console.log(event, data.id, data.status); // hand off to a queue, then answer fast
res.writeHead(200).end();
});
}).listen(3000);import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["AUTORANKO_WEBHOOK_SECRET"].encode() # whsec_…
@app.post("/webhooks/autoranko")
def autoranko_webhook():
raw = request.get_data() # the exact bytes, before any JSON parsing
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-Autoranko-Signature", "")):
abort(401)
payload = request.get_json()
print(payload["event"], payload["data"]["id"], payload["data"]["status"])
return "", 200<?php
$secret = getenv('AUTORANKO_WEBHOOK_SECRET'); // whsec_…
$raw = file_get_contents('php://input'); // the exact bytes, before any JSON parsing
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);
$received = $_SERVER['HTTP_X_AUTORANKO_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
$payload = json_decode($raw, true);
error_log("{$payload['event']} job {$payload['data']['id']}: {$payload['data']['status']}");
http_response_code(200);# body.json holds the exact raw request body. Compare the output with the
# X-Autoranko-Signature header: they must be identical.
signature=$(openssl dgst -sha256 -hmac "$AUTORANKO_WEBHOOK_SECRET" < body.json | awk '{print $NF}')
echo "sha256=$signature"Each sample reads the secret from the AUTORANKO_WEBHOOK_SECRET environment variable, compares in constant time and answers 401 when the signature doesn't match.
Test your endpoint
A known-good input for testing a verifier. Signed with this secret, this exact body must produce this signature.
| Secret | whsec_0123456789abcdef0123456789abcdef0123456789abcdef |
|---|---|
| Signature | sha256=c6c8d24a1eae8ac534944be6dffe67a2bc0ec3f43dc4abc37a00233fae2292f8 |
{"event":"job.published","created_at":"2026-11-02T09:04:31.226Z","data":{"id":"4","site_id":"1","topic":"Drip irrigation vs. sprinklers: which saves more water?","target_keyword":"drip irrigation vs sprinklers","status":"published","article_title":"Drip Irrigation vs. Sprinklers: Which Saves More Water?","meta_description":"Compare drip irrigation and sprinklers on water use, cost and upkeep to choose the right system for your lawn and garden.","wp_post_id":"1542","wp_link":"https://demo.autoranko.com/drip-irrigation-vs-sprinklers/"}}Run your handler with AUTORANKO_WEBHOOK_SECRET set to the test secret, then send it the test event. This command targets the Node.js sample on port 3000 (http://localhost:3000/webhooks/autoranko); change the URL for yours. Expect a 2xx; change any character of the body and expect 401.
curl -i -X POST http://localhost:3000/webhooks/autoranko \
-H "Content-Type: application/json" \
-H "X-Autoranko-Event: job.published" \
-H "X-Autoranko-Signature: sha256=c6c8d24a1eae8ac534944be6dffe67a2bc0ec3f43dc4abc37a00233fae2292f8" \
--data-binary '{"event":"job.published","created_at":"2026-11-02T09:04:31.226Z","data":{"id":"4","site_id":"1","topic":"Drip irrigation vs. sprinklers: which saves more water?","target_keyword":"drip irrigation vs sprinklers","status":"published","article_title":"Drip Irrigation vs. Sprinklers: Which Saves More Water?","meta_description":"Compare drip irrigation and sprinklers on water use, cost and upkeep to choose the right system for your lawn and garden.","wp_post_id":"1542","wp_link":"https://demo.autoranko.com/drip-irrigation-vs-sprinklers/"}}'Delivery and retries
| Method | POST |
|---|---|
| Timeout | 10 seconds per attempt |
| Retries | 1 more attempt, 2 seconds later, after a network error, a timeout or a 5xx |
| Not retried | Any other response, including 3xx and 4xx |
| Success | Any 2xx |
| Endpoints | Up to 5 per account |
- Add endpoints in Dashboard → API: up to 5 per account, each subscribed to any of job.review, job.published and job.failed (all three by default). The URL must be a public
httporhttpsaddress; localhost and private network addresses are rejected. Use HTTPS in production. - When adding an endpoint you get its signing secret (
whsec_followed by 48 hexadecimal characters). It's shown once; if you lose it, delete the endpoint and add it again. - Webhooks fire for every job on your account, whether it was created through the API or the dashboard, immediately or on a schedule.
- Each event is sent as a
POSTwith a JSON body to every active endpoint subscribed to it. Each attempt times out after 10 seconds. - A network error, a timeout or a
5xxresponse is retried once, 2 seconds later. Other responses, including4xx, aren't retried. Answer with any2xxquickly and do slow work asynchronously. - Dashboard → API shows each endpoint's last delivery: the HTTP status of the final attempt (empty when no response was received) and when it happened.
- Delivery is best-effort: there's no queue and no later redelivery. If your endpoint was down, catch up by listing jobs with GET /sites/{id}/jobs.
- A retry can deliver the same event twice (for example when your server processed it but answered after the timeout). Make handlers idempotent: a retried delivery has the same
event,data.idandcreated_at. - Point the webhook at its final URL. A
301or302redirect turns thePOSTinto aGETwithout the body.