Integrations
Custom API specification
The Custom API connector publishes to anything by having you implement one endpoint. This page is the full contract: what we send, how to verify it came from us, and what to return. Hand it to whoever builds or maintains the endpoint.
1. What you build
One HTTPS endpoint that accepts an HTTP POST with a JSON body. The same URL handles every event, we tell them apart with an event field in the body rather than asking for separate publish and update URLs. Your endpoint needs to:
- Accept
POSTrequests with a JSON body, over HTTPS, on a publicly reachable address. - Verify the request signature before trusting anything in the body.
- Respond within the timeout for the event (see section 7).
- Return a
2xxstatus with a small JSON acknowledgement (see section 4).
There are three events:
| Event | When we send it |
|---|---|
| connection.test | Once when a project connects, and again any time the connection is tested afterwards. Carries no content field, it only checks that we can reach your endpoint and that it accepts our headers. |
| content.publish | The first time an article is sent, and again for any later republish of an article we hold no stored id for. |
| content.update | A later send for an article we do hold a stored id for. Carries that id back to you as externalId. |
2. Request headers
Every request carries these headers, exactly as named here (header names are case-insensitive, but this is the casing we send):
| Header | Value | Notes |
|---|---|---|
Content-Type | application/json | Always present. |
User-Agent | EvergreenAI-Publisher/1 | Always present. |
X-Evergreen-Event | connection.test | One of the three event names. Also present as event in the body. |
X-Evergreen-Timestamp | 1755000000 | Unix time in seconds, as a string, taken when the request was built. |
X-Evergreen-Signature | sha256=<hex-encoded HMAC-SHA256 digest> | See section 6. |
X-Evergreen-Delivery | <uuid> | A fresh id for this specific HTTP attempt. Retries of the same event reuse the same idempotencyKey in the body but get a new delivery id. |
Depending on the authentication mode chosen when the connection was set up, exactly one more header is added:
| Auth mode | What is added |
|---|---|
| None | Nothing. The signature is the only credential, your endpoint must verify it. |
| Bearer token | Authorization: Bearer <token> |
| Custom header | A header of your choosing, for example X-Api-Key: <token>. The name is restricted to letters, numbers and hyphens, and cannot be Authorization, Content-Type, User-Agent, or start with X-Evergreen-, since those are ours. |
| Basic auth | Authorization: Basic <base64(username:password)> |
3. Request body
A content.publish request looks like this:
{
"version": 1,
"event": "content.publish",
"sentAt": "2026-08-12T14:32:07.412Z",
"idempotencyKey": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"test": false,
"content": {
"title": "How to Winterise a Heat Pump",
"slug": "how-to-winterise-a-heat-pump",
"markdown": "A heat pump left unprepared for winter can lose efficiency fast.\n\n## Check the outdoor unit\n\n...",
"html": "<p>A heat pump left unprepared for winter can lose efficiency fast.</p>\n<h2>Check the outdoor unit</h2>\n...",
"metaDescription": "Seven steps to protect your heat pump before the first frost.",
"targetKeyword": "winterise heat pump",
"headerImage": {
"url": "https://images.unsplash.com/photo-abc123",
"alt": "Frost forming on an outdoor heat pump unit",
"attribution": {
"name": "Jane Photographer",
"sourceName": "Unsplash",
"profileUrl": "https://unsplash.com/@janephotographer?utm_source=evergreen_ai&utm_medium=referral",
"sourceUrl": "https://unsplash.com/photos/abc123?utm_source=evergreen_ai&utm_medium=referral"
}
},
"schemaMarkup": {
"@context": "https://schema.org",
"@type": "Article",
"headline": "How to Winterise a Heat Pump"
}
},
"draft": false
}| Field | Type | Present when | Notes |
|---|---|---|---|
version | number | always | Currently 1. Bumped only alongside a documented change to this envelope, so you can branch on it if you need to. |
event | string | always | connection.test, content.publish, or content.update. |
sentAt | string | always | ISO 8601 UTC timestamp of when the payload was built. |
idempotencyKey | string | always | The article's internal id. Stable across retries of one attempt and across later republishes of the same article, so you can upsert on it without depending on the id you return. On connection.test, which has no article, this is a random id instead. |
test | boolean | always | true only when sent from the "Send test article" action in the settings panel. false everywhere else, including connection.test. |
externalId | string | content.update only | The id your endpoint returned the first time this article was published. Absent, not null, on the other two events. |
content | object | content.publish, content.update | See below. Absent on connection.test. |
draft | boolean | content.publish, content.update | Mirrors the draft/live toggle in the project’s publishing settings. Absent on connection.test. |
content object:
| Field | Type | Notes |
|---|---|---|
title | string | Plain text title. Never embedded as an H1 in markdown or html, since it is its own field. |
slug | string | null | null, not absent, when the article has no slug. |
markdown | string | Body only, no leading H1. |
html | string | The same content as markdown, pre-rendered to HTML, for an endpoint whose language has no Markdown parser handy. |
metaDescription | string | null | null, not absent, when empty. |
targetKeyword | string | The primary keyword the article targets. |
headerImage | object | null | null, not absent, when the article has no header image. See section 8, this is easy to miss. |
headerImage.url | string | Hotlinkable delivery URL, present only when headerImage is not null. |
headerImage.alt | string | Present only when headerImage is not null. |
headerImage.attribution | object | null | Present only when headerImage is not null. Itself null when the source does not require attribution. |
headerImage.attribution.name | string | Photographer or creator name, present only when attribution is not null. |
headerImage.attribution.sourceName | string | e.g. "Unsplash". |
headerImage.attribution.profileUrl | string | Link back to the creator’s profile. |
headerImage.attribution.sourceUrl | string | Link back to the source image. |
schemaMarkup | object | JSON-LD, ready to embed in a <script type="application/ld+json"> tag. An empty object when there is none. |
A content.update request carries the same content object again in full, there is no partial-update shape, plus the top-level externalId:
{
"version": 1,
"event": "content.update",
"sentAt": "2026-08-19T09:03:41.118Z",
"idempotencyKey": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"test": false,
"externalId": "post_123",
"content": { "...": "the full content object again, same shape as content.publish" },
"draft": false
}A connection.test request has no content or draft at all:
{
"version": 1,
"event": "connection.test",
"sentAt": "2026-08-12T14:30:00.000Z",
"idempotencyKey": "a5e1f9d0-6b2c-4e77-9c3a-1f9d0a5e1f9d",
"test": false
}4. What to return
Respond with a 2xx status and, ideally, a small JSON body:
{ "id": "post_123", "url": "https://yoursite.com/blog/your-slug" }externalId and externalUrl are accepted as aliases for id and url:
{ "externalId": "post_123", "externalUrl": "https://yoursite.com/blog/your-slug" }A numeric id is accepted and converted to a string. Nothing nested is read, a body shaped like { "data": { "id": "..." } } is not understood, guessing at response shapes produces silent wrong behaviour rather than a clear error, so keep id and url at the top level.
Returning an id is what makes updates possible. We store it and send it back to you as externalId on the next content.update for that article. Without it, every later send for that article arrives as another content.publish instead of an update, and unless your endpoint independently upserts on idempotencyKey or slug, you will get a duplicate. This is surfaced in the connected-project panel as a standing warning until your endpoint returns { "id": "..." }.
5. How we read each status
| Response | Result |
|---|---|
2xx, with a parseable id | Success. id and url stored, later sends for this article use content.update. |
2xx, without a parseable id | Success, but the connected-project panel shows a standing warning: the next send for this article is content.publish again, not an update. |
3xx, or an opaque redirect (status 0) | Error: "The endpoint answered with a redirect. We do not follow redirects, so enter the final URL instead." We send with redirect: manual. |
401 or 403 | Error: "The endpoint rejected our credentials. Check the authentication header and that your receiver is verifying against the current signing secret." |
404 on content.update | Treated as "this article no longer exists at your end." We clear the stored id and immediately retry as a fresh content.publish. No manual fix needed on your side unless that was unintended. |
404 on content.publish or connection.test | Error: "No endpoint at that URL. Check the path is exactly the one your receiver is mounted on." |
408, 429, or any 5xx | Error: "This is usually temporary, so we will try again." Counted against the same retry budget as any other failure. |
Any other 4xx | Error: your response body, up to 500 characters, is included verbatim so you can see what your own validation rejected. |
6. Verifying the signature
The signature is:
sha256=HMAC_SHA256(signingSecret, signedPayload).hex()where signedPayload is ${timestamp}.${rawRequestBody}, the exact X-Evergreen-Timestamp header value, a literal period, then the exact raw request body as we sent it. The signing secret is used as its literal text, the string shown to you in the connect form, not decoded from hex first. Paste it straight into your HMAC call.
Signing a re-parsed and re-serialised body will never match. This is the single most common way a first integration fails. Frameworks that parse JSON before your handler runs (Express's default JSON body parser, Flask's request.json, most PHP frameworks' request objects) hand you an object, not the original bytes, and re-serialising that object changes key order and whitespace. You must read the raw, unparsed body for the signature check specifically, then parse it afterwards for your own use.
Also reject any request whose timestamp is more than five minutes old, or unreasonably far in the future, which is what makes a captured request non-replayable. Compare signatures in constant time rather than with a plain string equality check.
Node.js (Express)
import express from 'express'
import crypto from 'crypto'
const app = express()
const SIGNING_SECRET = process.env.EVERGREEN_SIGNING_SECRET // used as literal text, not decoded from hex
// express.raw() keeps req.body as a Buffer instead of parsing it into an object.
// Signing a re-parsed, re-serialised body will not match ours: key order and
// whitespace in your parser's output differ from the exact bytes we sent.
app.post('/api/evergreen', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.header('X-Evergreen-Timestamp')
const signature = req.header('X-Evergreen-Signature')
const rawBody = req.body // Buffer, exactly as received on the wire
if (!timestamp || !signature) {
return res.status(401).send('Missing signature headers')
}
const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
if (!Number.isFinite(ageSeconds) || ageSeconds > 300) {
return res.status(401).send('Timestamp outside the 5 minute window')
}
const expected = 'sha256=' + crypto
.createHmac('sha256', SIGNING_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
const expectedBuf = Buffer.from(expected)
const actualBuf = Buffer.from(signature)
const valid = expectedBuf.length === actualBuf.length
&& crypto.timingSafeEqual(expectedBuf, actualBuf)
if (!valid) {
return res.status(401).send('Invalid signature')
}
const event = JSON.parse(rawBody.toString('utf8'))
// ... persist event.content to your CMS, then acknowledge it:
res.status(200).json({ id: 'your-internal-id', url: 'https://yoursite.com/blog/your-slug' })
})
PHP
<?php
// hash_hmac() and hash_equals() are built in, no packages required.
$signingSecret = getenv('EVERGREEN_SIGNING_SECRET'); // used as literal text, not decoded from hex
$timestamp = $_SERVER['HTTP_X_EVERGREEN_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_EVERGREEN_SIGNATURE'] ?? '';
$rawBody = file_get_contents('php://input'); // raw bytes exactly as received, not $_POST
if ($timestamp === '' || $signature === '') {
http_response_code(401);
exit('Missing signature headers');
}
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit('Timestamp outside the 5 minute window');
}
$expected = 'sha256=' . hash_hmac('sha256', "{$timestamp}.{$rawBody}", $signingSecret);
// hash_equals() is a constant-time string comparison.
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
$event = json_decode($rawBody, true);
// ... persist $event['content'] to your CMS, then acknowledge it:
header('Content-Type: application/json');
echo json_encode(['id' => 'your-internal-id', 'url' => 'https://yoursite.com/blog/your-slug']);
Python (Flask)
import hashlib
import hmac
import os
import time
from flask import Flask, jsonify, request
app = Flask(__name__)
SIGNING_SECRET = os.environ["EVERGREEN_SIGNING_SECRET"] # used as literal text, not decoded from hex
@app.post("/api/evergreen")
def receive():
timestamp = request.headers.get("X-Evergreen-Timestamp", "")
signature = request.headers.get("X-Evergreen-Signature", "")
raw_body = request.get_data() # raw bytes exactly as received, not request.json
# isdigit() rather than a bare truthiness check: int() on a non-numeric header
# would raise and turn a rejected request into a 500.
if not timestamp.isdigit() or not signature:
return "Missing signature headers", 401
if abs(int(time.time()) - int(timestamp)) > 300:
return "Timestamp outside the 5 minute window", 401
signed_payload = f"{timestamp}.".encode("utf-8") + raw_body
expected = "sha256=" + hmac.new(
SIGNING_SECRET.encode("utf-8"), signed_payload, hashlib.sha256
).hexdigest()
# hmac.compare_digest() is a constant-time string comparison.
if not hmac.compare_digest(expected, signature):
return "Invalid signature", 401
event = request.get_json()
# ... persist event["content"] to your CMS, then acknowledge it:
return jsonify({"id": "your-internal-id", "url": "https://yoursite.com/blog/your-slug"})
7. Retries, timeouts and duplicates
- Timeout: 30 seconds for content.publish and content.update, 10 seconds for connection.test.
- Up to three attempts total for a given send, on any failure, an unreachable endpoint, a timeout, or an error status.
- The idempotencyKey in the body is the same across every retry of one attempt and across every later republish of the same article, use it to upsert safely.
- X-Evergreen-Delivery is a new id on every attempt, including retries, use it if you need to distinguish individual HTTP deliveries rather than logical sends.
- We read at most 64 KB of your response body. Anything beyond that is discarded rather than buffered in full.
- Your endpoint must be reachable over HTTPS on a public address. Requests to private, loopback, link-local, and other internal-only addresses are refused, and this is checked before every send, not only when the connection is first saved.
8. The header image
The header image arrives only as content.headerImage. It is not embedded in markdown or html. An endpoint that ignores the field will publish every article with no image, silently, since nothing about the request will look wrong.
Download and rehost the image on your own storage rather than hotlinking headerImage.url, hotlinked images can disappear or change if the source rotates its delivery URLs. When headerImage.attributionis present, render it near the image, the image's licence requires that attribution to be shown.
9. Troubleshooting
| Message | Cause |
|---|---|
| The endpoint URL has to start with https://. Plain http is not accepted. | The saved URL uses http instead of https. |
| That is not a valid URL. It needs to start with https:// and include a full host name. | The saved URL does not parse as a URL at all. |
| Could not resolve {host}. Check the domain name is spelled correctly and is publicly reachable. | DNS lookup for the host failed or returned no addresses. |
| That URL resolves to a private or internal address. The endpoint has to be reachable on the public internet. | The host resolves to a loopback, private, link-local, or other non-public address. This includes an endpoint on localhost or behind a VPN with no public address. |
| The endpoint did not respond within N seconds. | Nothing was returned inside the timeout for that event, 30s for content, 10s for connection.test. Usually a slow handler or one waiting on something else before responding. |
| Could not reach the endpoint. Check it is online and serving HTTPS with a valid certificate. | Connection refused, connection reset, or a TLS handshake or certificate failure. |
| The endpoint answered with a redirect. We do not follow redirects, so enter the final URL instead. | Your endpoint returned a 3xx. Point the connection at the final URL rather than one that forwards. |
| The endpoint rejected our credentials. Check the authentication header and that your receiver is verifying against the current signing secret. | A 401 or 403 came back. Usually a mismatched auth header, or a signature check comparing against the wrong secret, most often after the secret was regenerated on one side only. |
| No endpoint at that URL. Check the path is exactly the one your receiver is mounted on. | A 404 on content.publish or connection.test, the path itself is wrong. |
| The endpoint returned 404 for this article, so it no longer exists there. Publishing it again as new. | A 404 on content.update, expected once the stored id is stale, we clear it and republish automatically. |
| The endpoint returned N. This is usually temporary, so we will try again. | A 408, 429, or 5xx, a transient failure on your side. Retried automatically within the existing attempt budget. |
| The endpoint rejected the article with N. It said: ... | Any other 4xx, typically your own validation rejecting a field. The message includes up to 500 characters of your response body verbatim. |
| Your endpoint did not return an id. | The response was a 2xx but had no top-level id or externalId, shown as a standing warning in the connected-project panel rather than a failed send. See section 4. |
| The header name can only contain letters, numbers and hyphens, for example X-Api-Key. | The custom auth header name chosen when connecting used disallowed characters. |
<name> is reserved. Pick a different header name, for example X-Api-Key. | The chosen header name collided with one we already set, Authorization, Content-Type, User-Agent, or anything starting with X-Evergreen-. |
Ready to connect it? Open your project's Settings, then Publishing, and choose Custom API.