/ Developers guide
Receiving Orova Social posts by webhook.
How to build an endpoint that receives the posts Orova Social sends, and verify the signature before you act on them.
Orova Social publishes straight to the platforms it connects to. When a post has to reach somewhere else — an internal channel, your own app, a platform Orova does not support yet — you use the API channel: Orova sends the post to an HTTP address of yours, signed with a secret key, and you take it from there. This guide is for a developer and describes the full request contract.
/ Overview
How it works
You register a receiving address (https://) and a secret key on the API channel of your project. Orova sends a POST request there with a JSON body and an X-Orova-Signature header — an HMAC-SHA256 of the exact bytes of that body, keyed with your secret. Your endpoint verifies the signature, takes the post, and returns a 2xx status.
- The receiving address must be https. Orova refuses to save the channel if the address is not
https://or has no host — it reports the codeapi_url. - One channel per address. Orova identifies the channel by the receiving address itself. Saving the same address again overwrites the existing channel instead of creating a duplicate.
- There is a Send test button. Once connected, the channel has a Send test button: Orova fires an
orova.testpayload at your endpoint and shows you the HTTP status it got back along with the round trip in milliseconds.
/ Setup
Setting it up in Orova
Open Social → Projects → your project → the Channels tab, and find the API box. It has two fields and one button.
- 1
Receiving URL (webhook). The address of your endpoint, starting with
https://. This is where every request goes. - 2
Secret key. The string used to sign. Leave it blank and Orova generates a random one — but the screen never shows it back, so generate a long string yourself, paste it in, and keep your own copy.
- 3
Click Save channel. The channel appears in the list with its receiving address. The secret is never displayed again.
- 4
Click Send test. Orova sends a sample
orova.testpayload. If your endpoint answers 2xx, Orova reports “Webhook returned 200 in … ms”. Any other status is reported with that code; an unreachable endpoint is reported as a network error.
/ Contract
The request contract
Every send arrives as one request shaped like this:
POST <your receiving URL>
Headers:
Content-Type: application/json
X-Orova-Signature: sha256=<hex HMAC-SHA256 of the raw body>
X-Orova-Event: orova.test
User-Agent: Orova-Social/1.0
Body (JSON):
{
"event": "orova.test",
"workspace_id": 12,
"project_id": 34,
"post": {
"title": "...",
"content": "...",
"media": [],
"channels": ["api"]
},
"sent_at": "2026-08-23T01:25:00Z"
}The body is compact UTF-8 JSON on a single line. Read event to tell the kinds apart — the Send test payload carries orova.test, and the X-Orova-Event header repeats the same value. Your endpoint should ignore unknown fields rather than reject them, so later additions do not break you.
| Field | Type | Description |
|---|---|---|
| event | string | The kind of event. The Send test payload is "orova.test". |
| workspace_id | integer | ID of the workspace the payload came from. |
| project_id | integer | null | ID of the project holding the channel, or null if the channel has none. |
| post | object | The post itself. |
| post.title | string | The post headline. |
| post.content | string | The body of the post, as text. |
| post.media | array | Attachments belonging to the post; an empty array for text-only posts. |
| post.channels | array of string | The channel kinds this post targets, for example ["api"]. |
| sent_at | string | When Orova sent it, ISO 8601 in UTC, ending in "Z". |
/ Verification
Verifying the signature
The X-Orova-Signature header reads sha256=<hex>, where <hex> is the HMAC-SHA256 of the raw bytes of the request body, keyed with the channel secret. Recompute it over exactly the bytes you received — parsing the JSON and re-serializing it will not match, because a single different space changes the signature.
Node.js
// Node.js / Express — verify X-Orova-Signature, then answer fast
import express from "express";
import crypto from "node:crypto";
const app = express();
const OROVA_SECRET = process.env.OROVA_SECRET; // the key you saved in Orova
// Keep the RAW bytes: the signature covers them, not re-serialized JSON.
app.post(
"/orova/social",
express.raw({ type: "application/json", limit: "5mb" }),
(req, res) => {
const sent = req.get("x-orova-signature") || "";
const mine =
"sha256=" +
crypto.createHmac("sha256", OROVA_SECRET).update(req.body).digest("hex");
// Constant-time compare — never use ===.
const a = Buffer.from(sent);
const b = Buffer.from(mine);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).json({ error: "bad signature" });
}
const payload = JSON.parse(req.body.toString("utf8"));
if (payload.event === "orova.test") {
return res.status(200).json({ ok: true }); // the Send test button
}
// Real post: hand the work to a queue and reply straight away.
queuePost(payload).catch(console.error);
return res.status(200).json({ ok: true });
},
);
app.listen(3000);Python
# Python / Flask — same check, same rules
import hashlib
import hmac
import json
import os
from flask import Flask, request
app = Flask(__name__)
OROVA_SECRET = os.environ["OROVA_SECRET"].encode("utf-8")
@app.post("/orova/social")
def orova_social():
raw = request.get_data() # bytes, exactly as sent
mine = "sha256=" + hmac.new(OROVA_SECRET, raw, hashlib.sha256).hexdigest()
sent = request.headers.get("X-Orova-Signature", "")
if not hmac.compare_digest(mine, sent): # constant-time compare
return {"error": "bad signature"}, 401
payload = json.loads(raw)
if payload.get("event") == "orova.test":
return {"ok": True}, 200
queue_post(payload) # do the slow work later
return {"ok": True}, 200Compare with a constant-time function (crypto.timingSafeEqual, hmac.compare_digest), never with a plain equality check. If the signature does not match, return 401 and stop — do not process the payload.
/ Replying
How to reply
Orova only looks at the HTTP status. Anything in the 2xx range counts as success; the response body can be whatever you like.
- Reply fast. The Send test button waits
10 secondsat most. Push slow work — downloading media, calling a third-party API — onto a queue and return 2xx straight away. - Orova never retries by itself. That is deliberate: automatic retries against a publishing API are how posts end up published twice. A failed send marks the post
failedwith the reason, and the user retries it from inside Orova. - Be ready for a repeat. When a user retries, the same payload arrives again. De-duplicate on your side — for example by remembering the
workspace_idandsent_atpairs you have handled.
/ Security
Security
- Always verify the signature. Your endpoint is a public URL — anyone can call it. The signature is the only thing that proves a payload really came from Orova.
- Keep the secret out of your code. Store it in an environment variable or a secrets manager, never hard-coded in a file you commit.
- Rotate the secret by saving again. Enter the same receiving address with the new secret and click Save channel — Orova overwrites the existing channel. Update your endpoint at the same time.
- Disconnecting deletes the secret. Disconnect the channel and Orova removes the stored secret and stops sending to that address.
/ Wrapping up
That is the whole contract
One https address, one secret, one signature check, one 2xx status — that is all it takes for posts from Orova Social to reach your systems. What happens next is yours to decide: where they get published, where they get stored, how they get logged.
/ Need a hand?
Stuck wiring up your endpoint? Open the Support section inside your workspace, or send us a message.
