On WordPress you don’t need this page: install the glimana Publisher plugin and paste the connection code from glimana. This document is for sites running their own software: you implement the two endpoints below and glimana sends signed requests to them.
Flow
- In glimana, create a connection under Settings → Publishing connections → Custom API and enter your endpoint base (e.g.
https://example.com/api/glimana). glimana shows a key id and secret once. - Expose two endpoints:
GET {base}/statusandPOST {base}/posts. - Verify the signature on every request as described below; reject anything that fails with 401 before doing any work.
- Click “Test connection” in glimana.
Request headers
| Header | Value |
|---|---|
X-Glimana-Key | Key id (16 hex characters) |
X-Glimana-Timestamp | Unix time (seconds, UTC) |
X-Glimana-Nonce | 32 hex characters, unique per request |
X-Glimana-Action | status, posts.create, posts.get:{id} or posts.update:{id} |
X-Glimana-Signature | v1= + 64 hex characters |
Signature
The canonical string is six lines joined with a single \n; the body is the request’s raw bytes (never re-serialize the JSON):
v1
{X-Glimana-Timestamp}
{X-Glimana-Nonce}
{HTTP METHOD, uppercase}
{X-Glimana-Action}
{sha256_hex(raw body)} ← for GET, the hash of the empty body
signature = "v1=" + hmac_sha256_hex(secret, canonical)
The action is signed instead of the URL so reverse proxies and rewrites cannot break the signature. You must bind actions to endpoints: /status accepts only status; /posts accepts posts.create on POST, posts.get:{id} on GET /posts?id= and posts.update:{id} on update (id = your post id). glimana sends posts.get when the archive page opens and before a resend; answer 404 + {"error":"glimana_not_found"} for a deleted post — otherwise these requests count as bad signatures and your server may lock glimana out.
Mandatory security rules
- Use a constant-time comparison (PHP
hash_equals, Nodecrypto.timingSafeEqual, Pythonhmac.compare_digest). - Time window: reject timestamps more than 300 seconds from your clock. Keep the server clock synced via NTP.
- Replay: remember every nonce for at least 10 minutes and reject repeats.
- HTTPS only. glimana never sends to http:// URLs or private/internal IPs.
- Enforce a body size limit (2 MB suggested) and verify the signature BEFORE parsing JSON.
- Don’t trust the content: sanitize
content_htmlagain with an allow-list (HTMLPurifier, DOMPurify, bleach): noscript,iframe,on*attributes orjavascript:links. - You own author and permissions: create posts as a user you choose; never accept author, role or settings from the request.
- Immediate publishing only behind a setting you control; default to drafts. Report it as
allow_publishin the status response. - Rate limit: e.g. 30 posts per hour; temporarily block IPs after repeated bad signatures.
- Keep the secret server-side only, out of repositories and logs. If you suspect a leak, click “Rotate key” in glimana; the old secret stops working immediately.
- Optionally allow only glimana’s egress IP:
209.74.74.26
GET {base}/status
Called by “Test connection”. Empty body. Response:
200 OK
{
"ok": true,
"site_name": "Example",
"plugin_version": "my-cms-1.0",
"allow_publish": false,
"categories": [ { "id": 3, "name": "Guides" } ],
"authors": [ { "id": 2, "name": "Editor" } ], ← authors glimana may pick per article (your allow-list)
"default_author": 2
}
POST {base}/posts
{
"idempotency_key": "0b6f6c0e-3c1a-4c55-9a51-2f1d5b7d9e10",
"title": "How to calculate rent increases",
"content_html": "<h2>…</h2><p>…</p>",
"excerpt": "140-155 character summary",
"meta_description": "140-155 character summary",
"slug": "how-to-calculate-rent-increases",
"status": "draft", ← draft | future | publish
"date_gmt": "2026-10-08T07:30:00Z", ← only for status=future; always UTC
"categories": [3],
"author_id": 2, ← optional; if not in your allow-list use the default author
"language": "en"
}
- idempotency_key: if a request repeats a key, do NOT create a second post; return the first one. glimana retries on network errors.
- future:
date_gmtmust be at least 1 minute ahead; if it is in the past, don’t publish — return 422 (otherwise the post would go live by accident). - publish: return 403 unless you have enabled it.
- faq: no longer sent since 7 October 2026. Google retired the FAQ rich result in May 2026, so outputting
FAQPagestructured data no longer does anything in Search. The Q&A still arrive visibly insidecontent_html. Older integrations that read the field should ignore it; FAQPage markup already on your site does no harm and does not need to be removed. - images (optional, max 3):
[{"key": "img0", "featured": true, "alt": "…", "mime": "image/jpeg", "data_base64": "…"}]. Thefeaturedone is the cover; the others replace the token<p>[[glimana-image:img1]]</p>in the content. If you don’t support images, remove the tokens. Security: never trustmimeor extensions; detect the type from the bytes, re-encode the image (drops metadata and appended payloads), choose the file name yourself, cap each image at 1.5 MB.
Success (new post 201, repeat 200):
{ "id": 812, "status": "draft", "url": "https://example.com/?p=812", "edit_url": "https://example.com/admin/posts/812" }
Errors: a suitable status (400, 401, 403, 413, 422, 429, 500) with {"code": "...", "message": "..."}. message is shown to the user; never put secrets in it. glimana does not retry 401/403; it retries 429 and 5xx twice.
Verification examples
PHP
function glimana_verify(string $secret, array $h, string $method, string $expectedAction, string $body): bool {
$ts = (int) ($h['x-glimana-timestamp'] ?? 0);
$nonce = (string) ($h['x-glimana-nonce'] ?? '');
if (($h['x-glimana-action'] ?? '') !== $expectedAction) return false;
if (abs(time() - $ts) > 300 || !preg_match('/^[a-f0-9]{32}$/', $nonce)) return false;
$canonical = implode("\n", ['v1', (string) $ts, $nonce, strtoupper($method), $expectedAction, hash('sha256', $body)]);
$expected = 'v1=' . hash_hmac('sha256', $canonical, $secret);
if (!hash_equals($expected, (string) ($h['x-glimana-signature'] ?? ''))) return false;
return nonce_first_seen($nonce, 600); // your store: Redis SET NX EX 600 / unique DB row
}
// $body = file_get_contents('php://input'); ← raw body
Node.js
const crypto = require('crypto');
function verify(secret, h, method, expectedAction, rawBody /* Buffer */) {
const ts = Number(h['x-glimana-timestamp']), nonce = String(h['x-glimana-nonce'] || '');
if (h['x-glimana-action'] !== expectedAction) return false;
if (Math.abs(Date.now() / 1000 - ts) > 300 || !/^[a-f0-9]{32}$/.test(nonce)) return false;
const canonical = ['v1', String(ts), nonce, method.toUpperCase(), expectedAction,
crypto.createHash('sha256').update(rawBody).digest('hex')].join('\n');
const expected = Buffer.from('v1=' + crypto.createHmac('sha256', secret).update(canonical).digest('hex'));
const got = Buffer.from(String(h['x-glimana-signature'] || ''));
return got.length === expected.length && crypto.timingSafeEqual(got, expected) && nonceFirstSeen(nonce);
}
// Express: app.post('/api/glimana/posts', express.raw({ type: 'application/json', limit: '2mb' }), ...)
Python
import hashlib, hmac, time, re
def verify(secret: bytes, h: dict, method: str, expected_action: str, raw_body: bytes) -> bool:
ts, nonce = int(h.get('x-glimana-timestamp', 0)), h.get('x-glimana-nonce', '')
if h.get('x-glimana-action') != expected_action: return False
if abs(time.time() - ts) > 300 or not re.fullmatch(r'[a-f0-9]{32}', nonce): return False
canonical = '\n'.join(['v1', str(ts), nonce, method.upper(), expected_action, hashlib.sha256(raw_body).hexdigest()])
expected = 'v1=' + hmac.new(secret, canonical.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, h.get('x-glimana-signature', '')) and nonce_first_seen(nonce)
Test vector
Check your implementation against this (NOT a real key):
secret : test_secret_do_not_use_0123456789abcdefABCDE
timestamp : 1767225600
nonce : 4f6b1c2d3e4f5a6b7c8d9e0f1a2b3c4d
method : POST
action : posts.create
body : {"idempotency_key":"0b6f6c0e-3c1a-4c55-9a51-2f1d5b7d9e10","title":"Hello","content_html":"<p>Hi</p>","status":"draft"}
body sha : 1d30b224b239dc0093e24dcbdd7ce6fa0ddc12f8ad49892e1cce4ebf335ed72a
signature : v1=ba6481f4574fb36b8e00ae74408bd54f81afc38fe446d8ab2c96ca0099af8abd
GET status (empty body, same timestamp/nonce):
signature : v1=ac5b10064ac28b51e173db45934e55781ea817b6d6c205a6f57faf30daa74eb8