Legal

Publishing API (for custom sites)

Last updated: 28 September 2026 · glimana.com

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

  1. 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.
  2. Expose two endpoints: GET {base}/status and POST {base}/posts.
  3. Verify the signature on every request as described below; reject anything that fails with 401 before doing any work.
  4. Click “Test connection” in glimana.

Request headers

HeaderValue
X-Glimana-KeyKey id (16 hex characters)
X-Glimana-TimestampUnix time (seconds, UTC)
X-Glimana-Nonce32 hex characters, unique per request
X-Glimana-Actionstatus, posts.create, posts.get:{id} or posts.update:{id}
X-Glimana-Signaturev1= + 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

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"
}

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