We send every published article as JSON to your URL.
One POST per article, signed, usually around 05:00 UTC. You respond with 2xx, ideally with the URL where the article ended up.
Already built against tittel, ingress, html and meta? They are still at the top level, and they will never be removed.
{
"versjon": 1,
"hendelse": "artikkel.publisert",
"hendelse_id": "hnd_9f2c1a7b4e5d6081",
"sendt": "2026-08-10T05:00:12Z",
"tittel": "How to choose a booking system for your clinic",
"ingress": "The wrong system costs you hours every week. Here is how to find the right one.",
"html": "<h2>Why a booking system</h2><p>…</p>",
"meta": "A guide to booking systems for clinics: what to look for, and what costs you time.",
"oppdatering": false,
"url": null,
"nettsted": {
"id": 12,
"url": "https://www.brightsmileclinic.com",
"merkenavn": "BrightSmile Clinic",
"sprak": "en-US"
},
"artikkel": {
"id": 4711,
"tittel": "How to choose a booking system for your clinic",
"slug": "how-to-choose-a-booking-system-for-your-clinic",
"ingress": "The wrong system costs you hours every week. Here is how to find the right one.",
"html": "<h2>Why a booking system</h2><p>…</p>",
"tekst": "Why a booking system\n\nMost clinics …",
"meta_beskrivelse": "A guide to booking systems for clinics: …",
"bilde_url": null,
"sokeord": "clinic booking system",
"ord": 1187,
"lesetid_min": 6,
"publisert_dato": "2026-08-10",
"publisert_url": null,
"ekstern_id": null
}
}
How it works#
Three steps. You do the first one once, we do the second one about three times a week, and you do the third one in a single function, already written for seven languages further down.
You enter your URL
In your dashboard, under Connections → the "Webhook / other" card. https:// only. Copy the secret at the same time, you will need it in step 3.
We POST signed JSON
One article per event, usually around 05:00 UTC. Content-Type: application/json; charset=utf-8, and the signature is in Oron-Signatur. We wait 30 seconds for your response.
You respond with 2xx
Verify the signature, store the article, respond 200. If you include {"url": "…"}, we know where the article actually lives, and your reports point to the right place.
Events#
Three types. The type is in both the Oron-Hendelse header and the hendelse field in the body. If you get a type you don't recognize, respond 200 and ignore it.
| Type | When |
|---|---|
artikkel.publisert | A new article has been published. Usually around 05:00 UTC. |
artikkel.oppdatert | An article you already received has been rewritten. Update the existing post, don't create a new one. See its own section. |
test.hendelse | You clicked "Send test event" in your dashboard. Real signature, made-up article. Do not publish it. |
The payload#
The body is JSON, UTF-8, with no escaping of non-ASCII characters. Field order is not guaranteed, so parse the JSON, not the text. Unknown fields may show up later; ignore them, and your receiver will handle the next version.
Field names are stable identifiers. They keep Oron's original (Norwegian) API names so existing integrations never break: tittel = title, ingress = intro, nettsted = website, artikkel = article, hendelse = event, Oron-Signatur = signature header. Each field is explained in English in the tables below.
{
"versjon": 1,
"hendelse": "artikkel.publisert",
"hendelse_id": "hnd_9f2c1a7b4e5d6081",
"sendt": "2026-08-10T05:00:12Z",
"tittel": "How to choose a booking system for your clinic",
"ingress": "The wrong system costs you hours every week. Here is how to find the right one.",
"html": "<h2>Why a booking system</h2><p>…</p>",
"meta": "A guide to booking systems for clinics: what to look for, and what costs you time.",
"oppdatering": false,
"url": null,
"nettsted": {
"id": 12,
"url": "https://www.brightsmileclinic.com",
"merkenavn": "BrightSmile Clinic",
"sprak": "en-US"
},
"artikkel": {
"id": 4711,
"tittel": "How to choose a booking system for your clinic",
"slug": "how-to-choose-a-booking-system-for-your-clinic",
"ingress": "The wrong system costs you hours every week. Here is how to find the right one.",
"html": "<h2>Why a booking system</h2><p>…</p>",
"tekst": "Why a booking system\n\nMost clinics …",
"meta_beskrivelse": "A guide to booking systems for clinics: …",
"bilde_url": null,
"sokeord": "clinic booking system",
"ord": 1187,
"lesetid_min": 6,
"publisert_dato": "2026-08-10",
"publisert_url": null,
"ekstern_id": null
}
}
tittel, ingress, html and meta sit at the top level and will never be removed. If you built against them before everything else was added, your integration keeps working. New fields are always added alongside, never instead.
The field names are Oron's original API names and stay as they are: tittel = title, ingress = intro/excerpt, nettsted = website, artikkel = article, hendelse = event.
Top-level fields#
| Field | Type | Nullable | What it is |
|---|---|---|---|
versjon | number | no | Payload version. Always 1 today. |
hendelse | string | no | One of the three types above. |
hendelse_id | string | no | Unique ID for the event, prefixed hnd_. Same on every attempt. Use it for idempotency. |
sendt | string | no | When we built the payload. ISO 8601 in UTC: 2026-08-10T05:00:12Z. |
tittelalways present | string | no | The article title. Same value as artikkel.tittel. |
ingressalways present | string | no | Short intro. Can be an empty string, but the field is always there. |
htmlalways present | string | no | The body as ready-made HTML. Today we only use <h2>, <p> and <br>: no lists, tables or images. No <html> or <body> around it, and no <h1>: the title is yours to set. |
metaalways present | string | yes | The meta description. Put it in <meta name="description">. The field is always present, but the value is null on articles that have no meta description; fall back to ingress. |
oppdatering | boolean | no | true when this is a rewrite of an article you already have. |
url | string | yes | null for a new article. On update: the URL where the article lives on your site, the key to match on. |
nettsted | object | no | The website the article belongs to. See below. |
artikkel | object | no | The full article with every detail. See below. |
nettsted#
| Field | Type | Nullable | What it is |
|---|---|---|---|
nettsted.id | number | no | Our ID for the website. Stable. |
nettsted.url | string | yes | The website as registered with us. If the field is empty on our side, we send null; we never make up an address. |
nettsted.merkenavn | string | yes | The business or brand name. null if not set. |
nettsted.sprak | string | no | Language code, e.g. en-US. If nothing is set on the website, we send en-US. |
artikkel#
| Field | Type | Nullable | What it is |
|---|---|---|---|
artikkel.id | number | no | Our ID for the article. Stable over time, safe to store as a reference. |
artikkel.tittel | string | no | Same as tittel. |
artikkel.slug | string | no | URL-friendly title. We also use it when we have to guess your URL; see Your response. |
artikkel.ingress | string | no | Same as ingress. |
artikkel.html | string | no | Same as html. |
artikkel.tekst | string | no | The same content as plain text, paragraphs separated by a blank line. Handy if you render it yourself or want to index it. |
artikkel.meta_beskrivelse | string | yes | Same as meta, with the same caveat: can be null. |
artikkel.bilde_url | string | yes | Hero image. Often null, so plan for it. |
artikkel.sokeord | string | yes | The keyword the article was written for. null if the article is not tied to one. |
artikkel.ord | number | no | Number of words in the body. |
artikkel.lesetid_min | number | no | Estimated reading time in minutes: words divided by 200, rounded, minimum 1. |
artikkel.publisert_dato | string | no | Publish date on our side, YYYY-MM-DD. On first publish it is the day we send it. |
artikkel.publisert_url | string | yes | null on first publish. On update: the URL we have stored for the article. |
artikkel.ekstern_id | string | yes | The ID you returned last time in {"id": "…"}. null if you never returned one. |
Headers#
Every request is a POST, with Content-Type set to application/json; charset=utf-8. We also send:
| Header | Example | Meaning |
|---|---|---|
Oron-Hendelse | artikkel.publisert | Event type. |
Oron-Hendelse-Id | hnd_9f2c1a7b4e5d6081 | Stable per event, the same on every attempt. The basis for idempotency. |
Oron-Levering | 1 | Attempt number, 1-based. Goes up to 4. |
Oron-Versjon | 1 | Payload version. Same as versjon in the body. |
Oron-Signatur | t=1786276812,v1=5257a8… | See Signature. |
User-Agent | Mozilla/5.0 (compatible; OronBot/1.0; +https://getoron.com) | Let it through any bot filtering. |
Header names are case-insensitive. Your framework may give them to you as oron-signatur or HTTP_ORON_SIGNATUR; it is the same header.
Signature#
Your endpoint is open on the internet. The signature is what separates us from anyone who guesses your URL. Check it: one HMAC calculation and one constant-time comparison. Ready-made code for seven languages is under Code examples.
Every connection has a secret shaped like whsec_ + 32 hexadecimal characters. You find it in your dashboard under Connections, in the "Webhook / other" card, field Signing secret, with a copy button. Keep it in environment variables, never in source code.
Oron-Signatur: t=1786276812,v1=5257a8bd9c1f4e0a7b3d2c8e6f1a4b7c9d0e3f2a1b8c5d4e7f0a9b2c3d6e1f4a
t= Unix time in seconds when we signed.v1= HMAC-SHA256 as lowercase hex.- More
vvalues may come later, comma-separated. Look forv1=and ignore keys you don't recognize.
The signed string#
Exactly this, with no spaces or line breaks added:
signed_string = t + "." + raw_body
t = "1786276812" the number from the header, as text
"." = a single period
raw_body = the body byte for byte, exactly as it arrived
(not parsed, not re-serialized, not trimmed)
v1 = hex( HMAC-SHA256( key = the secret, message = signed_string ) )
The key is the whole secret as text, including the whsec_ prefix, as UTF-8 bytes. Don't hex-decode it, and don't strip the prefix.
How to verify#
- Read the body as raw bytes, before any JSON parser touches it.
- Read
Oron-Signatur. Split on commas, then on=. Gettandv1. - Check that
tis not older than 5 minutes (recommended window). Reject it if it is; that stops replay attacks. - Compute
HMAC-SHA256overt + "." + raw_bodywith the secret as the key. Encode as lowercase hex. - Compare with
v1in constant time:hmac.compare_digest,crypto.timingSafeEqual,hash_equals. Never==. - If it doesn't match: respond
401and log it, which is what the code examples below do. Which 4xx code you choose doesn't matter to us; we never retry on 4xx (except 429). Never respond5xxto an invalid signature, because then we send the same forgery three more times.
Signing a re-serialized JSON. JSON.stringify(req.body) gives you a different byte stream than the one we sent: different whitespace, different field order, escaped non-ASCII characters. The signature will never match. You have to keep the raw body before the body parser runs.
If you don't check the signature at all, the integration still works. We send the header either way. But then anyone who knows your URL can publish on your website.
Your response#
Anything in 2xx is success. The body can be empty. Everything else counts as a failure.
If you return JSON with a url field, we store that as the article's published address. If you don't, we have to guess: we combine the website URL and artikkel.slug. If your article lives under /blog/… or /articles/…, the guess is wrong, and your reports and ranking tracking point to a page that doesn't exist.
{"url": "https://www.brightsmileclinic.com/blog/how-to-choose-a-booking-system", "id": "post_8812"}
| Field | Required | Effect |
|---|---|---|
url | optional | Stored as the article's publisert_url instead of the guessed one. Must start with http:// or https://, max 500 characters. |
id | optional | Stored as ekstern_id and sent back to you on updates. Max 100 characters. Use your post ID. |
If the body is empty or not JSON, nothing goes wrong; we just keep the guessed URL. Unknown fields in the response are ignored.
Respond fast#
We wait 30 seconds. If you need to generate images, warm a cache or call third parties: verify the signature, queue the job, and respond 200 right away. If you take longer than 30 seconds, we treat it as a timeout and retry, and then you may get the same article twice.
Retries#
Four attempts. No more.
Oron-Levering | Wait before the attempt | Total wait |
|---|---|---|
1 | none, sent right away | 0 s |
2 | 2 seconds | 2 s |
3 | 10 seconds | 12 s |
4 | 30 seconds | 42 s |
If all four fail, the background job queues the article and tries again later the same day, with the same hendelse_id. That is why it is worth storing that ID: if you do, you never get the same content twice, no matter how many times we retry. Every delivery is in the log in your dashboard, with your status code.
When we retry, and when we don't#
| We get | Retry? | Why |
|---|---|---|
| Network error (DNS, TLS, connection dropped) | yes | May be temporary. |
| Timeout after 30 seconds | yes | May be temporary. |
| HTTP 5xx | yes | Server error on your side. May pass. |
| HTTP 429 | yes | You rate-limited us. |
| HTTP 4xx (except 429) | no | 400, 401, 403, 404, 405, 422 … A repeat gives the same answer. Fix the receiver, and the next article works. |
| HTTP 3xx | no | We only follow redirects to public https addresses, and then we resend the same signed body as a POST. If the redirect points anywhere else, we stop. Simplest fix: give us the final address from the start. |
| HTTP 2xx | – | Done. |
The waits between attempts add up to 42 seconds. If every attempt uses the full 30-second timeout, the whole sequence takes up to about three minutes. It all happens in our background worker, so nobody is sitting there waiting for your response.
Idempotency#
Oron-Hendelse-Id (and hendelse_id in the body) is identical on all four attempts. A retry can mean you already stored the article, but your response never reached us.
So: store hendelse_id with a unique index. If you have seen it before, respond 200 and do nothing more. Then you never get duplicate content, whatever the network does.
artikkel.oppdatert#
When we rewrite an article you already have, we send the same envelope with three differences:
"hendelse": "artikkel.oppdatert"and"oppdatering": true"url"at the top level is set to the address we have stored for the article, the key to match onartikkel.ekstern_idis the ID you returned last time, if you gave us one
{
"versjon": 1,
"hendelse": "artikkel.oppdatert",
"hendelse_id": "hnd_2b71e0c4d9a35f18",
"sendt": "2026-09-02T05:00:07Z",
"tittel": "How to choose a booking system for your clinic",
"ingress": "The wrong system costs you hours every week. Here is how to find the right one.",
"html": "<h2>Why a booking system</h2><p>…</p>",
"meta": "A guide to booking systems for clinics: …",
"oppdatering": true,
"url": "https://www.brightsmileclinic.com/blog/how-to-choose-a-booking-system",
"artikkel": {
"id": 4711,
"publisert_url": "https://www.brightsmileclinic.com/blog/how-to-choose-a-booking-system",
"ekstern_id": "post_8812"
}
}
The rest of the fields are the same as on publish: the whole nettsted object and the whole artikkel object come along, with the new content.
How to find the right post#
- Did you store
artikkel.id? Use it. It is stable and the simplest. - Otherwise: look up
ekstern_id. That is your own ID, which you gave us last time. - Otherwise: look up
url.
Update the content of the existing post. Don't create a new one. Two pages with the same content compete with each other in Google, and then we are working against each other.
If you respond with {"url": "…"} here too, we update our address, which is useful if you moved the article.
Get started#
- Log in to your dashboard and go to Connections → the "Webhook / other" card.
- Paste the
https://address of your endpoint. https only, and the address must be reachable from the internet. - Copy the secret and put it in your environment variables.
- Click "Send test event". We send a real, signed
test.hendelseto your address right away, and show you the status code, the response time and the first characters of your response.
You also see the last ten deliveries to your website in your dashboard, with status code and error message. If you need to change the secret, rotate it in the same place. The old one stops working immediately, so update your receiver at the same time.
What the test event contains#
Exactly the same structure as a real publish, with made-up content. The signature is real, so it is perfect for testing your verification. The text is deliberately recognizable as fake: if someone publishes it blindly, you will notice right away. nettsted is your real website, and artikkel.id is 0; there is no real article behind it.
{
"versjon": 1,
"hendelse": "test.hendelse",
"hendelse_id": "hnd_c0ffee1234567890",
"sendt": "2026-08-09T12:00:00Z",
"tittel": "Test event from Oron: this is what an article looks like",
"ingress": "This is a test event. There is no real article behind it, and it should not be published.",
"html": "<h2>What this is</h2><p>Oron sends this event when you …</p>",
"meta": "Test event from Oron. Not a real article, do not publish.",
"oppdatering": false,
"url": null,
"nettsted": { "id": 12, "url": "https://www.brightsmileclinic.com", "merkenavn": "BrightSmile Clinic", "sprak": "en-US" },
"artikkel": {
"id": 0,
"tittel": "Test event from Oron: this is what an article looks like",
"slug": "test-event-from-oron-this-is-what-an-article-looks-like",
"sokeord": "test event",
"ord": 91,
"lesetid_min": 1,
"bilde_url": null,
"publisert_url": null,
"ekstern_id": null
}
}
The test event is sent with one attempt. You are sitting there waiting for the result, so we don't retry here. The button allows six clicks a minute; beyond that we return 429 to you in the dashboard.
Testing locally#
We can't reach localhost; we only POST to public https addresses. Two ways to work locally:
1. Put a tunnel in front of your machine and paste the tunnel URL into the dashboard while you develop. Then you get real signatures from us.
2. Build the request yourself. This gives you a valid signature without involving us at all:
SECRET='whsec_your_secret_here'
BODY='{"versjon":1,"hendelse":"test.hendelse","hendelse_id":"hnd_local_1","tittel":"Test","ingress":"Test","html":"<p>Hello</p>","meta":"Test","oppdatering":false,"url":null}'
T=$(date +%s)
SIG=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
curl -i -X POST http://localhost:3000/oron-webhook \
-H 'Content-Type: application/json; charset=utf-8' \
-H 'Oron-Hendelse: test.hendelse' \
-H 'Oron-Hendelse-Id: hnd_local_1' \
-H 'Oron-Levering: 1' \
-H 'Oron-Versjon: 1' \
-H "Oron-Signatur: t=$T,v1=$SIG" \
--data-binary "$BODY"
--data-binary matters: -d strips line breaks and can change the bytes, and then the signature doesn't match. printf '%s' is used so no trailing line break gets added.
Code examples#
Complete receivers that verify the signature, handle all three event types and respond with the real URL. Copy, swap in your secret and your storage, and you're done.
node-express.js
/**
* Oron webhook receiver: Node.js + Express
* ========================================
* Contract: Oron Webhook v1.
*
* Run:
* npm install express
* ORON_WEBHOOK_SECRET=whsec_... node node-express.js
*
* You find the secret in your Oron dashboard (copy button). NEVER put it in code.
*/
const express = require('express');
const crypto = require('crypto');
const app = express();
const PORT = process.env.PORT || 8801;
// The secret is read from the environment: never a string in source code, never in git.
const SECRET = process.env.ORON_WEBHOOK_SECRET;
if (!SECRET) {
console.error('ORON_WEBHOOK_SECRET is missing from the environment.');
process.exit(1);
}
// Replay protection window: 5 minutes (recommended in the contract, section 3).
const TOLERANSE_SEKUNDER = 300;
/* ------------------------------------------------------------------ *
* RAW BODY: trap number 1 with Express
* ------------------------------------------------------------------ *
* express.json() reads the stream, parses JSON and THROWS AWAY the original
* bytes. The signature is computed over the body exactly as it was sent.
* Re-serializing with JSON.stringify(req.body) gives a different byte stream
* (key order, whitespace, non-ASCII escaping) and the HMAC will never match.
* So: express.raw() on THIS route, and we parse the JSON ourselves only
* AFTER the signature is approved.
*
* type: '*/*' because we want the bytes whatever Content-Type says.
* Oron sends "application/json; charset=utf-8".
*
* Note: if you use express.json() globally (app.use(express.json())), register
* the webhook route BEFORE it. Alternatively keep the bytes in the verify callback:
* app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
*/
const raaKropp = express.raw({ type: '*/*', limit: '5mb' });
/**
* Constant-time comparison of two hex signatures.
*
* Why not just `a === b`? Because `===` stops at the first byte that differs,
* so the response time leaks HOW MANY characters were right. An attacker can
* then guess the signature one character at a time. crypto.timingSafeEqual()
* takes the same time wherever the buffers differ.
*
* timingSafeEqual() throws on different lengths, so check the length first.
* That is safe: a hex signature is always 64 characters, no secret there.
*/
function likeSignaturer(a, b) {
const bufA = Buffer.from(a, 'utf8');
const bufB = Buffer.from(b, 'utf8');
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
/**
* Verifies the Oron-Signatur header against the raw body.
* Returns { ok: true } or { ok: false, grunn: '...' } (grunn = reason).
*/
function verifiserSignatur(headerVerdi, raaBody) {
if (!headerVerdi) return { ok: false, grunn: 'missing Oron-Signatur' };
// The format is "t=1786276812,v1=5257a8…". More v keys may come later;
// we look for v1= and ignore anything we don't recognize (contract section 3).
let t = null;
let v1 = null;
for (const del of String(headerVerdi).split(',')) {
const i = del.indexOf('=');
if (i < 0) continue;
const nokkel = del.slice(0, i).trim();
const verdi = del.slice(i + 1).trim();
if (nokkel === 't') t = verdi;
else if (nokkel === 'v1') v1 = verdi;
}
if (!t || !v1) return { ok: false, grunn: 'invalid signature format' };
if (!/^\d+$/.test(t)) return { ok: false, grunn: 'invalid timestamp' };
// Compute our own HMAC over t + "." + raw body. raaBody is a Buffer,
// and we feed it in as bytes, with no detour through a string.
const hmac = crypto.createHmac('sha256', SECRET);
hmac.update(t, 'utf8');
hmac.update('.', 'utf8');
hmac.update(raaBody);
const forventet = hmac.digest('hex'); // lowercase hex, as in the contract
if (!likeSignaturer(forventet, v1.toLowerCase())) {
return { ok: false, grunn: 'signature does not match' };
}
// Replay protection. We check the time AFTER the HMAC is approved, so an
// unauthenticated header never steers our logic. An old, correctly signed
// call is exactly what a replay attack looks like. 5-minute window.
const alder = Math.floor(Date.now() / 1000) - Number(t);
if (alder > TOLERANSE_SEKUNDER) return { ok: false, grunn: 'timestamp too old' };
// Negative age = timestamp in the future. A little clock skew is normal,
// a lot is suspicious.
if (alder < -TOLERANSE_SEKUNDER) return { ok: false, grunn: 'timestamp in the future' };
return { ok: true };
}
/* ------------------------------------------------------------------ *
* DEMO STORE: replace with your database
* ------------------------------------------------------------------ */
const innlegg = new Map(); // url -> { id, tittel, ingress, html, meta }
const behandlede = new Map(); // hendelse_id -> finished response body
app.post('/oron-webhook', raaKropp, (req, res) => {
// req.body is a Buffer with the raw bytes here, not an object.
const raaBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
// 1) SIGNATURE FIRST. We don't touch the content until the sender is confirmed.
// 401 = not authenticated. 4xx means "don't retry", which is right:
// a retry with the same wrong secret gives the same answer.
const sjekk = verifiserSignatur(req.get('Oron-Signatur'), raaBody);
if (!sjekk.ok) {
console.warn('Rejected webhook:', sjekk.grunn);
return res.status(401).json({ feil: 'invalid signature' });
}
// 2) Now, and only now, can we parse the body.
let data;
try {
data = JSON.parse(raaBody.toString('utf8'));
} catch (e) {
// 400: the body is broken. A retry sends the same broken body,
// so we ask Oron not to (no retries on 4xx except 429).
return res.status(400).json({ feil: 'invalid JSON' });
}
const hendelse = data.hendelse;
// hendelse_id is the same on all four delivery attempts.
const hendelseId = data.hendelse_id || req.get('Oron-Hendelse-Id');
// 3) IDEMPOTENCY.
// Oron tries up to 4 times (right away, +2s, +10s, +30s) on 5xx, 429,
// timeout and network errors. If we publish the article but don't answer
// before the timeout, EXACTLY the same hendelse_id comes again.
// Without dedupe, the customer gets four identical posts.
//
// >>> Do this in your database, not in memory: <<<
// Create a table `oron_hendelser(hendelse_id TEXT PRIMARY KEY,
// svar TEXT, ts TIMESTAMP)` and INSERT with a UNIQUE constraint on
// hendelse_id in the SAME transaction as you store the post.
// If the constraint fires (duplicate), fetch the stored response and
// return 200 with it. An in-memory Map survives neither a restart nor
// two processes behind a load balancer.
if (hendelseId && behandlede.has(hendelseId)) {
// Return the SAME response as the first time, so Oron gets the real URL
// even when it was the response that got lost.
return res.status(200).json(behandlede.get(hendelseId));
}
// 4) test.hendelse: the customer clicked "Send test event" in the dashboard.
// Real signature, made-up article. NEVER publish it.
// We respond 200 so the test in the dashboard turns green.
if (hendelse === 'test.hendelse') {
return res.status(200).json({ ok: true, melding: 'test event received, not published' });
}
try {
let svar;
if (hendelse === 'artikkel.publisert') {
// NEW article.
const slug = (data.artikkel && data.artikkel.slug) || lagSlug(data.tittel);
const url = `${nettstedBasis(data)}/blog/${slug}`;
const postId = `post_${innlegg.size + 1}`;
innlegg.set(url, {
id: postId,
tittel: data.tittel,
ingress: data.ingress,
html: data.html, // ready-made HTML from Oron
meta: data.meta, // meta description
});
// Return the REAL URL. Without it Oron guesses "website/slug", and
// reports + ranking tracking may point to a URL that doesn't exist.
// `id` is stored as ekstern_id and comes back on later updates.
svar = { url, id: postId };
} else if (hendelse === 'artikkel.oppdatert') {
// UPDATE: rewrite what already exists, don't create a new one.
// The key is the top-level `url` field.
const url = data.url;
if (!url) {
return res.status(400).json({ feil: 'missing url on update' });
}
const fins = innlegg.get(url);
if (fins) {
fins.tittel = data.tittel;
fins.ingress = data.ingress;
fins.html = data.html;
fins.meta = data.meta;
svar = { url, id: fins.id };
} else {
// If we don't know the URL, create it rather than lose the article.
const postId = `post_${innlegg.size + 1}`;
innlegg.set(url, {
id: postId, tittel: data.tittel, ingress: data.ingress,
html: data.html, meta: data.meta,
});
svar = { url, id: postId };
}
} else {
// Unknown event type. 200, not 4xx/5xx: Oron shouldn't retry something
// we deliberately ignore, and new types may come later.
return res.status(200).json({ ok: true, melding: 'ignored event type' });
}
if (hendelseId) behandlede.set(hendelseId, svar);
// 5) Respond fast with 2xx. Put heavy follow-up work (images, cache purge,
// indexing) in a queue AFTER this response. Oron has a 30-second timeout,
// and a timeout costs you three extra deliveries.
return res.status(200).json(svar);
} catch (e) {
// 5xx = "try again". Right for temporary errors (database down, disk full).
// Oron retries after 2s, 10s and 30s.
console.error('Error while saving:', e);
return res.status(500).json({ feil: 'internal error, try again' });
}
});
function nettstedBasis(data) {
const u = (data.nettsted && data.nettsted.url) || 'https://example.com';
return u.replace(/\/+$/, '');
}
function lagSlug(tittel) {
return String(tittel || '')
.toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'article';
}
app.listen(PORT, () => console.log(`Oron webhook receiver listening on :${PORT}`));
Run as a real HTTP server against seven signed deliveries.
app/api/oron/route.ts
/**
* Oron webhook receiver: Next.js App Router
* =========================================
* Contract: Oron Webhook v1.
*
* Put this file in: app/api/oron-webhook/route.ts
* The endpoint becomes: https://your-domain.com/api/oron-webhook
*
* Environment variable (.env.local, and in your Vercel/hosting panel):
* ORON_WEBHOOK_SECRET=whsec_...
*/
import crypto from 'node:crypto';
// The webhook must run on the Node runtime: we use node:crypto and need
// timingSafeEqual. The Edge runtime doesn't have it (you'd use WebCrypto there).
export const runtime = 'nodejs';
// Never cache a webhook endpoint. Without this, Next.js may try to
// statically analyze/optimize the route in some setups.
export const dynamic = 'force-dynamic';
// Replay protection window: 5 minutes.
const TOLERANSE_SEKUNDER = 300;
type Verifisering = { ok: true } | { ok: false; grunn: string };
/**
* Constant-time comparison.
*
* `a === b` on a signature is a real vulnerability. String comparison stops
* at the first different character, so the response time reveals how far into
* the signature the attacker got. timingSafeEqual takes the same time wherever
* the bytes differ.
*
* Check the length first, because timingSafeEqual throws on different lengths.
* Nothing leaks: a hex SHA-256 is always 64 characters.
*/
function likeSignaturer(a: string, b: string): boolean {
const bufA = Buffer.from(a, 'utf8');
const bufB = Buffer.from(b, 'utf8');
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
/** Verifies "Oron-Signatur" against the raw body. */
function verifiserSignatur(
headerVerdi: string | null,
raaBody: string,
secret: string,
): Verifisering {
if (!headerVerdi) return { ok: false, grunn: 'missing Oron-Signatur' };
// "t=1786276812,v1=5257a8…". Ignore unknown keys: a v2 may come later,
// and old code should keep working.
let t: string | null = null;
let v1: string | null = null;
for (const del of headerVerdi.split(',')) {
const i = del.indexOf('=');
if (i < 0) continue;
const nokkel = del.slice(0, i).trim();
const verdi = del.slice(i + 1).trim();
if (nokkel === 't') t = verdi;
else if (nokkel === 'v1') v1 = verdi;
}
if (!t || !v1) return { ok: false, grunn: 'invalid signature format' };
if (!/^\d+$/.test(t)) return { ok: false, grunn: 'invalid timestamp' };
// HMAC-SHA256 over t + "." + raw body, lowercase hex.
const forventet = crypto
.createHmac('sha256', secret)
.update(`${t}.${raaBody}`, 'utf8')
.digest('hex');
if (!likeSignaturer(forventet, v1.toLowerCase())) {
return { ok: false, grunn: 'signature does not match' };
}
// The replay window is checked after the HMAC is approved.
const alder = Math.floor(Date.now() / 1000) - Number(t);
if (alder > TOLERANSE_SEKUNDER) return { ok: false, grunn: 'timestamp too old' };
if (alder < -TOLERANSE_SEKUNDER) return { ok: false, grunn: 'timestamp in the future' };
return { ok: true };
}
/* ------------------------------------------------------------------ *
* DEMO STORE: replace with Prisma/Drizzle/Supabase/whatever you use.
* A module-level Map doesn't survive a serverless instance; it is only
* there so the example runs as-is.
* ------------------------------------------------------------------ */
type Innlegg = { id: string; tittel: string; ingress: string; html: string; meta: string };
const innlegg = new Map<string, Innlegg>();
const behandlede = new Map<string, unknown>();
export async function POST(req: Request): Promise<Response> {
const secret = process.env.ORON_WEBHOOK_SECRET;
if (!secret) {
// Misconfiguration on our side → 500 so Oron retries after you have
// set the variable.
return Response.json({ feil: 'ORON_WEBHOOK_SECRET is missing' }, { status: 500 });
}
// RAW BODY. await req.text() gives the body exactly as it arrived.
// If you use req.json() here, you lose the bytes, and any re-serialization
// (key order, whitespace, \u-escaping) gives a different byte stream than
// the one Oron signed. The HMAC will never match.
// Also: the body can only be read ONCE. Read it as text here and parse
// with JSON.parse afterwards; don't call req.json() as well.
const raaBody = await req.text();
// 1) SIGNATURE FIRST, before we interpret a single byte of the content.
const sjekk = verifiserSignatur(req.headers.get('oron-signatur'), raaBody, secret);
if (!sjekk.ok) {
console.warn('Rejected Oron webhook:', sjekk.grunn);
// 401 and no retries (4xx except 429 is not retried).
return Response.json({ feil: 'invalid signature' }, { status: 401 });
}
// 2) Now we can safely parse.
let data: any;
try {
data = JSON.parse(raaBody);
} catch {
return Response.json({ feil: 'invalid JSON' }, { status: 400 });
}
const hendelse: string = data.hendelse;
const hendelseId: string | undefined =
data.hendelse_id ?? req.headers.get('oron-hendelse-id') ?? undefined;
// 3) IDEMPOTENCY on hendelse_id.
// Oron delivers up to 4 times with the SAME hendelse_id. If you don't
// respond within 30 s, exactly the same event comes again even if you
// already stored the article.
//
// >>> REPLACE this Map with your database: <<<
// Prisma example: model `OronHendelse { hendelseId String @id,
// svar Json, opprettet DateTime @default(now()) }`.
// Run create() on hendelseId inside the same $transaction that
// creates the post. If you get P2002 (unique constraint), the event
// is already handled: fetch the row and return the stored response
// with 200. Dedupe is then atomic even when two serverless instances
// each get an attempt at the same time.
if (hendelseId && behandlede.has(hendelseId)) {
// Same response as the first time: Oron should get the real URL even
// when it was only our response that got lost.
return Response.json(behandlede.get(hendelseId), { status: 200 });
}
// 4) test.hendelse must NEVER be published, but must be acknowledged with 2xx
// so "Send test event" in the dashboard turns green.
if (hendelse === 'test.hendelse') {
return Response.json(
{ ok: true, melding: 'test event received, not published' },
{ status: 200 },
);
}
try {
let svar: { url: string; id: string };
if (hendelse === 'artikkel.publisert') {
// NEW article.
const slug: string = data.artikkel?.slug || lagSlug(data.tittel);
const url = `${nettstedBasis(data)}/blog/${slug}`;
const postId = `post_${innlegg.size + 1}`;
innlegg.set(url, {
id: postId,
tittel: data.tittel,
ingress: data.ingress,
html: data.html,
meta: data.meta,
});
// Return the REAL URL. Without it Oron stores a guessed
// "website/slug" that may not exist, and ranking tracking follows the
// wrong page. `id` comes back as ekstern_id on updates.
svar = { url, id: postId };
} else if (hendelse === 'artikkel.oppdatert') {
// UPDATE in place. The key is the top-level `url`, not the slug.
const url: string | null = data.url;
if (!url) return Response.json({ feil: 'missing url on update' }, { status: 400 });
const fins = innlegg.get(url);
if (fins) {
fins.tittel = data.tittel;
fins.ingress = data.ingress;
fins.html = data.html;
fins.meta = data.meta;
svar = { url, id: fins.id };
} else {
// Unknown URL: create rather than lose the article.
const postId = `post_${innlegg.size + 1}`;
innlegg.set(url, {
id: postId, tittel: data.tittel, ingress: data.ingress,
html: data.html, meta: data.meta,
});
svar = { url, id: postId };
}
} else {
// Unknown type: 200, not an error. New event types should not cause
// four failed delivery attempts at Oron.
return Response.json({ ok: true, melding: 'ignored event type' }, { status: 200 });
}
if (hendelseId) behandlede.set(hendelseId, svar);
// 5) Respond fast. Put anything heavy (image import, revalidatePath,
// indexing) in a background job. Oron gives you 30 seconds.
return Response.json(svar, { status: 200 });
} catch (e) {
// 5xx → Oron retries after 2 s, 10 s and 30 s.
console.error('Error while saving:', e);
return Response.json({ feil: 'internal error, try again' }, { status: 500 });
}
}
function nettstedBasis(data: any): string {
const u: string = data?.nettsted?.url || 'https://example.com';
return u.replace(/\/+$/, '');
}
function lagSlug(tittel: string): string {
return String(tittel || '')
.toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'article';
}
The POST function run unchanged against a real Request object. tsc --noEmit --strict: 0 errors.
oron-webhook.php
<?php
/**
* Oron webhook receiver: plain PHP, no framework
* ==============================================
* Contract: Oron Webhook v1.
*
* Put the file at e.g. https://yourdomain.com/oron-webhook.php and paste
* that URL into your Oron dashboard.
*
* Set the secret as an environment variable, never in code, never in git:
* Apache: SetEnv ORON_WEBHOOK_SECRET whsec_...
* nginx: fastcgi_param ORON_WEBHOOK_SECRET whsec_...;
* php-fpm: env[ORON_WEBHOOK_SECRET] = whsec_...
*
* Test locally: ORON_WEBHOOK_SECRET=whsec_... php -S 127.0.0.1:8803 php-plain.php
*/
declare(strict_types=1);
// Replay protection window: 5 minutes.
const TOLERANSE_SEKUNDER = 300;
/** Sends a JSON response and exits. */
function svar(int $kode, array $data): never
{
http_response_code($kode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/**
* Reads an HTTP header regardless of setup.
*
* PHP puts incoming headers in $_SERVER with the HTTP_ prefix, uppercase,
* dashes replaced by underscores: "Oron-Signatur" becomes
* $_SERVER['HTTP_ORON_SIGNATUR']. getallheaders() isn't available everywhere
* (missing on some php-fpm setups), so $_SERVER is the main path.
*/
function header_verdi(string $navn): ?string
{
$nokkel = 'HTTP_' . strtoupper(str_replace('-', '_', $navn));
if (isset($_SERVER[$nokkel])) {
return (string) $_SERVER[$nokkel];
}
if (function_exists('getallheaders')) {
foreach (getallheaders() as $k => $v) {
if (strcasecmp($k, $navn) === 0) {
return (string) $v;
}
}
}
return null;
}
/**
* Verifies "Oron-Signatur" against the raw body.
* Returns null if all is well, otherwise a short reason.
*/
function verifiser_signatur(?string $header, string $raa_body, string $secret): ?string
{
if ($header === null || $header === '') {
return 'missing Oron-Signatur';
}
// The format is "t=1786276812,v1=5257a8…". Ignore unknown keys,
// more v values may come later.
$t = null;
$v1 = null;
foreach (explode(',', $header) as $del) {
$pos = strpos($del, '=');
if ($pos === false) {
continue;
}
$nokkel = trim(substr($del, 0, $pos));
$verdi = trim(substr($del, $pos + 1));
if ($nokkel === 't') {
$t = $verdi;
} elseif ($nokkel === 'v1') {
$v1 = $verdi;
}
}
if ($t === null || $v1 === null) {
return 'invalid signature format';
}
if (preg_match('/^\d+$/', $t) !== 1) {
return 'invalid timestamp';
}
// HMAC-SHA256 over t + "." + raw body. hash_hmac() returns lowercase hex.
$forventet = hash_hmac('sha256', $t . '.' . $raa_body, $secret);
/*
* CONSTANT TIME: hash_equals(), not ===.
*
* === on strings compares byte by byte and stops at the first difference,
* so the response time reveals how many characters were right, and an
* attacker can guess the signature one character at a time. This is a real
* vulnerability. hash_equals() takes the same time wherever the strings differ.
*
* (hash_equals() also returns false on different lengths, which is safe:
* a hex SHA-256 is always 64 characters and no secret.)
*/
if (!hash_equals($forventet, strtolower($v1))) {
return 'signature does not match';
}
// Replay protection, checked after the HMAC is approved. An old, correctly
// signed call is exactly what a replay attack looks like.
$alder = time() - (int) $t;
if ($alder > TOLERANSE_SEKUNDER) {
return 'timestamp too old';
}
if ($alder < -TOLERANSE_SEKUNDER) {
return 'timestamp in the future';
}
return null;
}
/* ------------------------------------------------------------------ *
* DEMO STORE: a JSON file. REPLACE with your database (PDO/MySQL).
* The file is only there so the example runs as-is.
* ------------------------------------------------------------------ */
function lager_fil(): string
{
return sys_get_temp_dir() . '/oron_php_lager.json';
}
function les_lager(): array
{
$fil = lager_fil();
if (!is_file($fil)) {
return ['innlegg' => [], 'behandlede' => []];
}
$data = json_decode((string) file_get_contents($fil), true);
return is_array($data) ? $data : ['innlegg' => [], 'behandlede' => []];
}
function skriv_lager(array $lager): void
{
file_put_contents(lager_fil(), json_encode($lager, JSON_UNESCAPED_UNICODE), LOCK_EX);
}
function nettsted_basis(array $data): string
{
$u = $data['nettsted']['url'] ?? 'https://example.com';
return rtrim((string) $u, '/');
}
function lag_slug(string $tittel): string
{
$s = mb_strtolower($tittel, 'UTF-8');
$s = (string) preg_replace('/[^a-z0-9]+/u', '-', $s);
$s = trim($s, '-');
return $s === '' ? 'article' : mb_substr($s, 0, 80);
}
/* ================================================================== *
* HANDLING
* ================================================================== */
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
// 405 is 4xx → Oron doesn't retry. Right: a GET never becomes a POST.
svar(405, ['feil' => 'POST only']);
}
$secret = getenv('ORON_WEBHOOK_SECRET') ?: ($_SERVER['ORON_WEBHOOK_SECRET'] ?? '');
if ($secret === '') {
// Misconfiguration on our side → 5xx so Oron retries later.
svar(500, ['feil' => 'ORON_WEBHOOK_SECRET is missing']);
}
/*
* RAW BODY.
* php://input gives the bytes exactly as they arrived. NEVER use $_POST here:
* $_POST is only filled for form-encoded bodies, and re-serializing JSON
* (key order, whitespace, escaping) gives a different byte stream than the
* one Oron signed, so the HMAC will never match.
* Note: php://input can only be read once on some setups, so we store it
* in a variable right away.
*/
$raa_body = file_get_contents('php://input');
if ($raa_body === false) {
$raa_body = '';
}
// 1) SIGNATURE FIRST, before we interpret a single byte of the content.
$grunn = verifiser_signatur(header_verdi('Oron-Signatur'), $raa_body, (string) $secret);
if ($grunn !== null) {
error_log('Rejected Oron webhook: ' . $grunn);
// 401 = not authenticated, and 4xx means "don't retry".
svar(401, ['feil' => 'invalid signature']);
}
// 2) Now we can safely parse.
$data = json_decode($raa_body, true);
if (!is_array($data)) {
// 400: broken body. A retry sends the same broken body.
svar(400, ['feil' => 'invalid JSON']);
}
$hendelse = (string) ($data['hendelse'] ?? '');
$hendelse_id = (string) ($data['hendelse_id'] ?? header_verdi('Oron-Hendelse-Id') ?? '');
$lager = les_lager();
/*
* 3) IDEMPOTENCY on hendelse_id.
* Oron delivers up to 4 times (right away, +2 s, +10 s, +30 s) on 5xx, 429,
* timeout and network errors, with the SAME hendelse_id. If you don't
* respond within 30 seconds, exactly the same event comes again even if the
* article is already stored. Without dedupe, the customer gets four
* identical posts.
*
* >>> Use your database here, not the JSON file below: <<<
* CREATE TABLE oron_hendelser (
* hendelse_id VARCHAR(64) PRIMARY KEY,
* svar TEXT,
* opprettet DATETIME DEFAULT CURRENT_TIMESTAMP
* );
* Run "INSERT INTO oron_hendelser (hendelse_id, svar) VALUES (?, ?)"
* in the SAME transaction as you store the post. If you get a duplicate
* error (SQLSTATE 23000), the event is already handled: fetch the row
* and return the stored response with 200. Dedupe is then atomic even
* when two attempts hit two php-fpm processes at the same time.
*/
if ($hendelse_id !== '' && isset($lager['behandlede'][$hendelse_id])) {
// Same response as the first time: Oron should get the real URL even
// when it was only our response that got lost.
svar(200, $lager['behandlede'][$hendelse_id]);
}
// 4) test.hendelse: the customer clicked "Send test event". Real signature,
// made-up article. NEVER publish it, but acknowledge it with 2xx.
if ($hendelse === 'test.hendelse') {
svar(200, ['ok' => true, 'melding' => 'test event received, not published']);
}
try {
if ($hendelse === 'artikkel.publisert') {
// NEW article.
$slug = (string) ($data['artikkel']['slug'] ?? lag_slug((string) ($data['tittel'] ?? '')));
$url = nettsted_basis($data) . '/blog/' . $slug;
$post_id = 'post_' . (count($lager['innlegg']) + 1);
$lager['innlegg'][$url] = [
'id' => $post_id,
'tittel' => $data['tittel'] ?? '',
'ingress' => $data['ingress'] ?? '',
'html' => $data['html'] ?? '', // ready-made HTML from Oron
'meta' => $data['meta'] ?? '',
];
// Return the REAL URL. Without it Oron stores a guessed
// "website/slug" that may not exist, and the reports point wrong.
$ut = ['url' => $url, 'id' => $post_id];
} elseif ($hendelse === 'artikkel.oppdatert') {
// UPDATE in place, don't create a new post.
// The key is the top-level "url" field.
$url = $data['url'] ?? null;
if (!is_string($url) || $url === '') {
svar(400, ['feil' => 'missing url on update']);
}
if (isset($lager['innlegg'][$url])) {
$post_id = $lager['innlegg'][$url]['id'];
$lager['innlegg'][$url] = [
'id' => $post_id,
'tittel' => $data['tittel'] ?? '',
'ingress' => $data['ingress'] ?? '',
'html' => $data['html'] ?? '',
'meta' => $data['meta'] ?? '',
];
} else {
// Unknown URL: create rather than lose the article.
$post_id = 'post_' . (count($lager['innlegg']) + 1);
$lager['innlegg'][$url] = [
'id' => $post_id,
'tittel' => $data['tittel'] ?? '',
'ingress' => $data['ingress'] ?? '',
'html' => $data['html'] ?? '',
'meta' => $data['meta'] ?? '',
];
}
$ut = ['url' => $url, 'id' => $post_id];
} else {
// Unknown event type → 200, not an error. New types should not cause
// four failed delivery attempts at Oron.
svar(200, ['ok' => true, 'melding' => 'ignored event type']);
}
if ($hendelse_id !== '') {
$lager['behandlede'][$hendelse_id] = $ut;
}
skriv_lager($lager);
// 5) Respond fast with 2xx. Put heavy follow-up work (image import, cache
// purge, sitemap) in a queue AFTER the response. Oron has a 30 s timeout,
// and a timeout costs you three extra deliveries.
svar(200, $ut);
} catch (Throwable $e) {
// 5xx → Oron retries after 2 s, 10 s and 30 s. Right for temporary
// errors like a database outage or a full disk.
error_log('Error while saving: ' . $e->getMessage());
svar(500, ['feil' => 'internal error, try again']);
}
Run as a real HTTP server against seven signed deliveries.
app/Http/Controllers/OronWebhookController.php
<?php
/**
* Oron webhook receiver: Laravel
* ==============================
* Contract: Oron Webhook v1.
*
* File location: app/Http/Controllers/OronWebhookController.php
*
* ---------------------------------------------------------------------------
* ROUTE: put this in routes/web.php (or routes/api.php):
*
* use App\Http\Controllers\OronWebhookController;
* Route::post('/oron-webhook', OronWebhookController::class)
* ->name('oron.webhook');
*
* IMPORTANT, CSRF:
* Oron sends no CSRF token. If the route is in routes/web.php it MUST be
* excluded, or Laravel responds 419 and Oron won't even retry (419 is 4xx).
*
* Laravel 11/12, bootstrap/app.php:
* ->withMiddleware(function (Middleware $middleware) {
* $middleware->validateCsrfTokens(except: ['oron-webhook']);
* })
*
* Laravel 10 and older, app/Http/Middleware/VerifyCsrfToken.php:
* protected $except = ['oron-webhook'];
*
* If you put the route in routes/api.php, CSRF is already off, but the
* prefix becomes /api/oron-webhook.
* ---------------------------------------------------------------------------
*
* ENVIRONMENT, .env (never in code, never in git):
* ORON_WEBHOOK_SECRET=whsec_...
*
* Ideally also add to config/services.php:
* 'oron' => ['webhook_secret' => env('ORON_WEBHOOK_SECRET')],
* so `php artisan config:cache` works: env() returns null when the config
* is cached, config() does not. We read via config() with env() as fallback.
*/
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Throwable;
class OronWebhookController extends Controller
{
/** Replay protection window: 5 minutes. */
private const TOLERANSE_SEKUNDER = 300;
public function __invoke(Request $request): JsonResponse
{
$secret = (string) (config('services.oron.webhook_secret') ?? env('ORON_WEBHOOK_SECRET', ''));
if ($secret === '') {
// Misconfiguration on our side → 5xx, so Oron retries
// after the variable is set.
return response()->json(['feil' => 'ORON_WEBHOOK_SECRET is missing'], 500);
}
/*
* RAW BODY.
* $request->getContent() gives the bytes exactly as they arrived.
* NEVER use $request->all() / ->input() / ->json() for the signature:
* they have already parsed the JSON, and re-serializing gives a different
* byte stream than the one Oron signed, so the HMAC will never match.
*/
$raaBody = $request->getContent();
// 1) SIGNATURE FIRST, before we interpret a single byte of the content.
$grunn = $this->verifiserSignatur($request->header('Oron-Signatur'), $raaBody, $secret);
if ($grunn !== null) {
Log::warning('Rejected Oron webhook', ['grunn' => $grunn]);
// 401 = not authenticated. 4xx means "don't retry", which is
// right: a retry with the wrong secret gives the same answer.
return response()->json(['feil' => 'invalid signature'], 401);
}
// 2) Now, and only now, can we parse the body.
$data = json_decode($raaBody, true);
if (!is_array($data)) {
// 400: broken body, a retry sends the same broken body.
return response()->json(['feil' => 'invalid JSON'], 400);
}
$hendelse = (string) ($data['hendelse'] ?? '');
$hendelseId = (string) ($data['hendelse_id'] ?? $request->header('Oron-Hendelse-Id') ?? '');
$lager = $this->lesLager();
/*
* 3) IDEMPOTENCY on hendelse_id.
* Oron delivers up to 4 times (right away, +2 s, +10 s, +30 s) on 5xx,
* 429, timeout and network errors, with the SAME hendelse_id.
* If you don't respond within 30 seconds, exactly the same event comes
* again even if the article is already stored.
*
* >>> Put the dedupe in YOUR database here: <<<
* Migration:
* Schema::create('oron_hendelser', function (Blueprint $t) {
* $t->string('hendelse_id')->primary();
* $t->json('svar');
* $t->timestamps();
* });
* And in code, instead of the demo store below:
* return DB::transaction(function () use ($data, $hendelseId) {
* try {
* OronHendelse::create(['hendelse_id' => $hendelseId, 'svar' => []]);
* } catch (QueryException $e) {
* // 23000 = unique violation → already handled
* $rad = OronHendelse::find($hendelseId);
* return response()->json($rad->svar, 200);
* }
* ... store the post, update the row with the response ...
* });
* The point is that the INSERT on hendelse_id happens in the SAME
* transaction as the post is stored. Dedupe is then atomic even
* when two queue workers each get an attempt at the same time.
*/
if ($hendelseId !== '' && isset($lager['behandlede'][$hendelseId])) {
// Same response as the first time: Oron should get the real URL
// even when it was only our response that got lost.
return response()->json($lager['behandlede'][$hendelseId], 200);
}
// 4) test.hendelse: the customer clicked "Send test event" in the dashboard.
// Real signature, made-up article. NEVER publish it,
// but acknowledge it with 2xx so the test turns green.
if ($hendelse === 'test.hendelse') {
return response()->json(
['ok' => true, 'melding' => 'test event received, not published'],
200
);
}
try {
if ($hendelse === 'artikkel.publisert') {
// NEW article.
// Real Laravel: $post = Post::create([...]);
// $url = route('blog.show', $post->slug);
$slug = (string) ($data['artikkel']['slug'] ?? $this->lagSlug((string) ($data['tittel'] ?? '')));
$url = $this->nettstedBasis($data) . '/blog/' . $slug;
$postId = 'post_' . (count($lager['innlegg']) + 1);
$lager['innlegg'][$url] = [
'id' => $postId,
'tittel' => $data['tittel'] ?? '',
'ingress' => $data['ingress'] ?? '',
'html' => $data['html'] ?? '',
'meta' => $data['meta'] ?? '',
];
// Return the REAL URL. Without it Oron stores a guessed
// "website/slug" that may not exist, and ranking tracking
// follows the wrong page. `id` comes back as ekstern_id on
// later updates.
$ut = ['url' => $url, 'id' => $postId];
} elseif ($hendelse === 'artikkel.oppdatert') {
// UPDATE in place, don't create a new post.
// The key is the top-level "url" field.
// Real Laravel: Post::where('publisert_url', $url)->first()
// ?? Post::find($data['artikkel']['ekstern_id'])
$url = $data['url'] ?? null;
if (!is_string($url) || $url === '') {
return response()->json(['feil' => 'missing url on update'], 400);
}
if (isset($lager['innlegg'][$url])) {
$postId = (string) $lager['innlegg'][$url]['id'];
} else {
// Unknown URL: create rather than lose the article.
$postId = 'post_' . (count($lager['innlegg']) + 1);
}
$lager['innlegg'][$url] = [
'id' => $postId,
'tittel' => $data['tittel'] ?? '',
'ingress' => $data['ingress'] ?? '',
'html' => $data['html'] ?? '',
'meta' => $data['meta'] ?? '',
];
$ut = ['url' => $url, 'id' => $postId];
} else {
// Unknown event type → 200, not an error. New types should not
// cause four failed delivery attempts at Oron.
return response()->json(['ok' => true, 'melding' => 'ignored event type'], 200);
}
if ($hendelseId !== '') {
$lager['behandlede'][$hendelseId] = $ut;
}
$this->skrivLager($lager);
// 5) Respond fast with 2xx. Put heavy follow-up work (image import,
// cache purge, sitemap) in a queue:
// ProcessArticle::dispatch($postId)->afterResponse();
// Oron has a 30-second timeout, and a timeout costs you three
// extra deliveries.
return response()->json($ut, 200);
} catch (Throwable $e) {
// 5xx → Oron retries after 2 s, 10 s and 30 s. Right for
// temporary errors (database down, disk full).
Log::error('Error while saving Oron article', ['feil' => $e->getMessage()]);
return response()->json(['feil' => 'internal error, try again'], 500);
}
}
/**
* Verifies "Oron-Signatur" against the raw body.
* Returns null when all is well, otherwise a short reason.
*/
private function verifiserSignatur(?string $header, string $raaBody, string $secret): ?string
{
if ($header === null || $header === '') {
return 'missing Oron-Signatur';
}
// "t=1786276812,v1=5257a8…". Unknown keys are ignored: more v values
// may come later, and old code should keep working.
$t = null;
$v1 = null;
foreach (explode(',', $header) as $del) {
$pos = strpos($del, '=');
if ($pos === false) {
continue;
}
$nokkel = trim(substr($del, 0, $pos));
$verdi = trim(substr($del, $pos + 1));
if ($nokkel === 't') {
$t = $verdi;
} elseif ($nokkel === 'v1') {
$v1 = $verdi;
}
}
if ($t === null || $v1 === null) {
return 'invalid signature format';
}
if (preg_match('/^\d+$/', $t) !== 1) {
return 'invalid timestamp';
}
// HMAC-SHA256 over t + "." + raw body. hash_hmac() returns lowercase hex.
$forventet = hash_hmac('sha256', $t . '.' . $raaBody, $secret);
/*
* CONSTANT TIME: hash_equals(), never === or ==.
*
* A normal string comparison stops at the first byte that differs, so
* the response time reveals how many characters the attacker got right,
* and the signature can be guessed one character at a time. That is a
* real vulnerability. hash_equals() takes the same time either way.
*/
if (!hash_equals($forventet, strtolower($v1))) {
return 'signature does not match';
}
// Replay protection, checked after the HMAC is approved. An old,
// correctly signed call is exactly what a replay attack looks like.
$alder = time() - (int) $t;
if ($alder > self::TOLERANSE_SEKUNDER) {
return 'timestamp too old';
}
if ($alder < -self::TOLERANSE_SEKUNDER) {
return 'timestamp in the future';
}
return null;
}
/* -------------------------------------------------------------- *
* DEMO STORE: a JSON file in storage. REPLACE with Eloquent.
* It is only here so the example runs as-is.
* -------------------------------------------------------------- */
private function lagerFil(): string
{
return sys_get_temp_dir() . '/oron_laravel_lager.json';
}
private function lesLager(): array
{
$fil = $this->lagerFil();
if (!is_file($fil)) {
return ['innlegg' => [], 'behandlede' => []];
}
$data = json_decode((string) file_get_contents($fil), true);
return is_array($data) ? $data : ['innlegg' => [], 'behandlede' => []];
}
private function skrivLager(array $lager): void
{
file_put_contents($this->lagerFil(), json_encode($lager, JSON_UNESCAPED_UNICODE), LOCK_EX);
}
private function nettstedBasis(array $data): string
{
$u = $data['nettsted']['url'] ?? 'https://example.com';
return rtrim((string) $u, '/');
}
private function lagSlug(string $tittel): string
{
// Real Laravel: Str::slug($tittel). Done explicitly here so the
// example has no dependencies.
$s = mb_strtolower($tittel, 'UTF-8');
$s = (string) preg_replace('/[^a-z0-9]+/u', '-', $s);
$s = trim($s, '-');
return $s === '' ? 'article' : mb_substr($s, 0, 80);
}
}
Run as a file, against a minimal Illuminate scaffold. Laravel's own HTTP stack and the CSRF layer are not tested; remember to exclude the route from CSRF.
oron_webhook.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Oron webhook receiver: Python + Flask
=====================================
Contract: Oron Webhook v1.
Run:
pip install flask
ORON_WEBHOOK_SECRET=whsec_... python3 python-flask.py
Behind nginx/gunicorn in production:
ORON_WEBHOOK_SECRET=whsec_... gunicorn -w 2 -b 127.0.0.1:8804 python-flask:app
You find the secret in your Oron dashboard (copy button). Never in code, never in git.
"""
import hashlib
import hmac
import json
import os
import re
import sys
import time
import unicodedata
from flask import Flask, request, jsonify
app = Flask(__name__)
# The secret is read from the environment.
SECRET = os.environ.get("ORON_WEBHOOK_SECRET", "")
if not SECRET:
print("ORON_WEBHOOK_SECRET is missing from the environment.", file=sys.stderr)
sys.exit(1)
# Replay protection window: 5 minutes.
TOLERANSE_SEKUNDER = 300
# ------------------------------------------------------------------ #
# DEMO STORE: replace with your database (SQLAlchemy/Django/psycopg).
# The dicts only live in memory and disappear on restart; they are here
# so the example runs as-is.
# ------------------------------------------------------------------ #
INNLEGG = {} # url -> dict
BEHANDLEDE = {} # hendelse_id -> finished response body
def verifiser_signatur(header_verdi, raa_body):
"""
Verifies "Oron-Signatur" against the raw body.
Returns None when all is well, otherwise a short reason.
"""
if not header_verdi:
return "missing Oron-Signatur"
# The format is "t=1786276812,v1=5257a8…". Ignore unknown keys:
# more v values may come later, and old code should keep working.
t = None
v1 = None
for del_ in header_verdi.split(","):
if "=" not in del_:
continue
nokkel, _, verdi = del_.partition("=")
nokkel = nokkel.strip()
verdi = verdi.strip()
if nokkel == "t":
t = verdi
elif nokkel == "v1":
v1 = verdi
if not t or not v1:
return "invalid signature format"
if not re.fullmatch(r"\d+", t):
return "invalid timestamp"
# HMAC-SHA256 over t + "." + raw body. We build the signed string as
# BYTES: the timestamp, a period, then the body exactly as it arrived.
# hexdigest() returns lowercase hex, as the contract requires.
signert = t.encode("utf-8") + b"." + raa_body
forventet = hmac.new(SECRET.encode("utf-8"), signert, hashlib.sha256).hexdigest()
# CONSTANT TIME: hmac.compare_digest(), never ==.
#
# `forventet == v1` compares character by character and stops at the first
# difference, so the response time reveals how many characters were right,
# and an attacker can guess the signature one character at a time. That is a
# real vulnerability. compare_digest() takes the same time wherever the
# strings differ, and also returns False on different lengths (safe: a hex
# SHA-256 is always 64 characters and no secret).
if not hmac.compare_digest(forventet, v1.lower()):
return "signature does not match"
# Replay protection. Checked AFTER the HMAC is approved, so an
# unauthenticated header never steers our logic. An old, correctly signed
# call is exactly what a replay attack looks like.
alder = int(time.time()) - int(t)
if alder > TOLERANSE_SEKUNDER:
return "timestamp too old"
# Negative age = timestamp in the future. A little clock skew is normal,
# a lot is suspicious.
if alder < -TOLERANSE_SEKUNDER:
return "timestamp in the future"
return None
def nettsted_basis(data):
u = (data.get("nettsted") or {}).get("url") or "https://example.com"
return u.rstrip("/")
def lag_slug(tittel):
s = (tittel or "").lower()
s = unicodedata.normalize("NFD", s)
s = "".join(c for c in s if not unicodedata.combining(c))
s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
return (s or "article")[:80]
@app.post("/oron-webhook")
def oron_webhook():
# RAW BODY.
# request.get_data() gives the bytes exactly as they arrived. NEVER use
# request.json or request.get_json() for the signature: they have already
# parsed the JSON, and re-serializing with json.dumps() gives a different
# byte stream (key order, whitespace, \u-escaping; Oron sends
# ensure_ascii=False, i.e. raw UTF-8) than the one Oron signed.
# The HMAC will never match.
# cache=True (default) lets the stream be read again later.
raa_body = request.get_data(cache=True, as_text=False)
# 1) SIGNATURE FIRST, before we interpret a single byte of the content.
grunn = verifiser_signatur(request.headers.get("Oron-Signatur"), raa_body)
if grunn:
app.logger.warning("Rejected Oron webhook: %s", grunn)
# 401 = not authenticated. 4xx means "don't retry", which is right:
# a retry with the wrong secret gives the same answer.
return jsonify({"feil": "invalid signature"}), 401
# 2) Now, and only now, can we parse the body.
try:
data = json.loads(raa_body.decode("utf-8"))
if not isinstance(data, dict):
raise ValueError("not an object")
except Exception:
# 400: broken body. A retry sends the same broken body, so we
# ask Oron not to (no retries on 4xx except 429).
return jsonify({"feil": "invalid JSON"}), 400
hendelse = data.get("hendelse")
# hendelse_id is the same on all four delivery attempts.
hendelse_id = data.get("hendelse_id") or request.headers.get("Oron-Hendelse-Id")
# 3) IDEMPOTENCY on hendelse_id.
# Oron delivers up to 4 times (right away, +2 s, +10 s, +30 s) on 5xx,
# 429, timeout and network errors, with the SAME hendelse_id. If you
# don't respond within 30 seconds, exactly the same event comes again
# even if the article is already stored. Without dedupe, the customer
# gets four identical posts.
#
# >>> Use your database here, not the dicts above: <<<
# CREATE TABLE oron_hendelser (
# hendelse_id TEXT PRIMARY KEY,
# svar TEXT,
# opprettet TIMESTAMP DEFAULT now()
# );
# INSERT on hendelse_id in the SAME transaction as you store the
# post. If you get IntegrityError (unique violation), the event is
# already handled: fetch the row and return the stored response with
# 200. Dedupe is then atomic even when two gunicorn workers each get
# an attempt at the same time; an in-memory dict survives neither a
# restart nor multiple processes.
if hendelse_id and hendelse_id in BEHANDLEDE:
# Same response as the first time: Oron should get the real URL even
# when it was only our response that got lost.
return jsonify(BEHANDLEDE[hendelse_id]), 200
# 4) test.hendelse: the customer clicked "Send test event" in the dashboard.
# Real signature, made-up article. NEVER publish it, but acknowledge
# it with 2xx so the test in the dashboard turns green.
if hendelse == "test.hendelse":
return jsonify({"ok": True, "melding": "test event received, not published"}), 200
try:
if hendelse == "artikkel.publisert":
# NEW article.
slug = (data.get("artikkel") or {}).get("slug") or lag_slug(data.get("tittel"))
url = "%s/blog/%s" % (nettsted_basis(data), slug)
post_id = "post_%d" % (len(INNLEGG) + 1)
INNLEGG[url] = {
"id": post_id,
"tittel": data.get("tittel"),
"ingress": data.get("ingress"),
"html": data.get("html"), # ready-made HTML from Oron
"meta": data.get("meta"),
}
# Return the REAL URL. Without it Oron stores a guessed
# "website/slug" that may not exist, and reports and ranking
# tracking point to the wrong page. `id` is stored as ekstern_id
# and comes back on later updates.
ut = {"url": url, "id": post_id}
elif hendelse == "artikkel.oppdatert":
# UPDATE in place, don't create a new post.
# The key is the top-level "url" field.
url = data.get("url")
if not url:
return jsonify({"feil": "missing url on update"}), 400
fins = INNLEGG.get(url)
if fins:
post_id = fins["id"]
else:
# Unknown URL: create rather than lose the article.
post_id = "post_%d" % (len(INNLEGG) + 1)
INNLEGG[url] = {
"id": post_id,
"tittel": data.get("tittel"),
"ingress": data.get("ingress"),
"html": data.get("html"),
"meta": data.get("meta"),
}
ut = {"url": url, "id": post_id}
else:
# Unknown event type → 200, not an error. New types should not
# cause four failed delivery attempts at Oron.
return jsonify({"ok": True, "melding": "ignored event type"}), 200
if hendelse_id:
BEHANDLEDE[hendelse_id] = ut
# 5) Respond fast with 2xx. Put heavy follow-up work (image import,
# cache purge, sitemap, search index) in a queue (Celery/RQ) AFTER
# this response. Oron has a 30-second timeout, and a timeout costs
# you three extra deliveries.
return jsonify(ut), 200
except Exception as e:
# 5xx → Oron retries after 2 s, 10 s and 30 s. Right for temporary
# errors like a database outage or a full disk.
app.logger.exception("Error while saving: %s", e)
return jsonify({"feil": "internal error, try again"}), 500
if __name__ == "__main__":
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "8804")))
Run as a real HTTP server against seven signed deliveries.
Program.cs
// Oron webhook receiver: .NET 8 minimal API
// ==========================================
// Contract: Oron Webhook v1.
//
// Run:
// dotnet new web -o OronWebhook (and replace Program.cs with this file)
// export ORON_WEBHOOK_SECRET=whsec_...
// dotnet run
//
// This file IS the whole program (top-level statements). The secret is read
// from the environment: never a string in the source code, never in git or in a
// committed appsettings.json.
using System.Collections.Concurrent;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// The secret from the environment.
var secret = Environment.GetEnvironmentVariable("ORON_WEBHOOK_SECRET");
if (string.IsNullOrEmpty(secret))
{
Console.Error.WriteLine("ORON_WEBHOOK_SECRET is missing from the environment.");
return 1;
}
var secretBytes = Encoding.UTF8.GetBytes(secret);
// Time window for replay protection: 5 minutes.
const int ToleranseSekunder = 300;
// ------------------------------------------------------------------ //
// DEMO STORE: replace with EF Core / Dapper / whatever you use.
// Kept in memory only so the example runs as is.
// ------------------------------------------------------------------ //
var innlegg = new ConcurrentDictionary<string, Innlegg>();
var behandlede = new ConcurrentDictionary<string, object>();
app.MapPost("/oron-webhook", async (HttpRequest req) =>
{
// RAW BODY, as BYTES.
// We read the stream straight into a byte[] and leave it alone.
// NEVER use [FromBody] model binding or ReadFromJsonAsync for the
// signature: the JSON has already been parsed, and re-serializing gives a
// different byte stream (key order, whitespace, escaping of non-ASCII and
// emoji) than the one Oron signed. The HMAC will never match.
// We also skip the detour through a string: bytes in, bytes out, no
// encoding back and forth that could change anything.
byte[] raaBody;
using (var ms = new MemoryStream())
{
await req.Body.CopyToAsync(ms);
raaBody = ms.ToArray();
}
// 1) SIGNATURE FIRST, before we interpret a single byte of the content.
var grunn = VerifiserSignatur(
req.Headers["Oron-Signatur"].ToString(), raaBody, secretBytes, ToleranseSekunder);
if (grunn is not null)
{
Console.Error.WriteLine($"Rejected Oron webhook: {grunn}");
// 401 = not authenticated. 4xx means "don't retry", which is right:
// a retry with the wrong secret gives the same answer.
return Results.Json(new { feil = "invalid signature" }, statusCode: 401);
}
// 2) Now, and only now, can we parse the body.
JsonObject? data;
try
{
data = JsonNode.Parse(raaBody)?.AsObject();
if (data is null) throw new JsonException("not an object");
}
catch (Exception)
{
// 400: broken body. A retry sends the same broken body, so we
// ask Oron not to (no retries on 4xx except 429).
return Results.Json(new { feil = "invalid JSON" }, statusCode: 400);
}
var hendelse = data["hendelse"]?.GetValue<string>();
// hendelse_id is the same on all four delivery attempts.
var hendelseId = data["hendelse_id"]?.GetValue<string>()
?? req.Headers["Oron-Hendelse-Id"].ToString();
if (string.IsNullOrEmpty(hendelseId)) hendelseId = null;
// 3) IDEMPOTENCY on hendelse_id.
// Oron delivers up to 4 times (right away, +2 s, +10 s, +30 s) on 5xx,
// 429, timeout and network errors, with the SAME hendelse_id. If you
// don't respond within 30 seconds, exactly the same event comes again
// even if the article is already stored. Without dedupe, the customer
// gets four identical posts.
//
// >>> Use your database HERE, not the ConcurrentDictionary: <<<
// EF Core: an OronHendelse entity with HendelseId as primary key
// (or a UNIQUE index). Call SaveChangesAsync() on it in the SAME
// transaction as you store the post:
// using var tx = db.Database.BeginTransaction();
// db.OronHendelser.Add(new OronHendelse { HendelseId = hendelseId });
// try { await db.SaveChangesAsync(); }
// catch (DbUpdateException) { /* duplicate: fetch stored response, return 200 */ }
// Dedupe is then atomic even when two instances behind a load balancer
// each get an attempt at the same time; an in-memory dictionary
// survives neither a restart nor multiple processes.
if (hendelseId is not null && behandlede.TryGetValue(hendelseId, out var tidligere))
{
// Same response as the first time: Oron should get the real URL even
// when it was only our response that got lost.
return Results.Json(tidligere, statusCode: 200);
}
// 4) test.hendelse: the customer clicked "Send test event" in the dashboard.
// Real signature, made-up article. NEVER publish it, but acknowledge
// it with 2xx so the test in the dashboard turns green.
if (hendelse == "test.hendelse")
{
return Results.Json(
new { ok = true, melding = "test event received, not published" }, statusCode: 200);
}
try
{
object ut;
if (hendelse == "artikkel.publisert")
{
// NEW article.
var slug = data["artikkel"]?["slug"]?.GetValue<string>()
?? LagSlug(data["tittel"]?.GetValue<string>());
var url = $"{NettstedBasis(data)}/blog/{slug}";
var postId = $"post_{innlegg.Count + 1}";
innlegg[url] = new Innlegg(
postId,
data["tittel"]?.GetValue<string>(),
data["ingress"]?.GetValue<string>(),
data["html"]?.GetValue<string>(), // finished HTML from Oron
data["meta"]?.GetValue<string>());
// Return the REAL URL. Without it, Oron stores a guessed
// "website/slug" that may not exist, and reports and rank
// tracking point to the wrong page. `id` is stored as ekstern_id
// and comes back on later updates.
ut = new { url, id = postId };
}
else if (hendelse == "artikkel.oppdatert")
{
// UPDATE in place: don't create a new post.
// The key is the top-level "url" field.
var url = data["url"]?.GetValue<string>();
if (string.IsNullOrEmpty(url))
{
return Results.Json(new { feil = "missing url on update" }, statusCode: 400);
}
// Unknown URL: create rather than lose the article.
var postId = innlegg.TryGetValue(url, out var fins)
? fins.Id
: $"post_{innlegg.Count + 1}";
innlegg[url] = new Innlegg(
postId,
data["tittel"]?.GetValue<string>(),
data["ingress"]?.GetValue<string>(),
data["html"]?.GetValue<string>(),
data["meta"]?.GetValue<string>());
ut = new { url, id = postId };
}
else
{
// Unknown event type: 200, not an error. New types should not cause
// four failed delivery attempts at Oron.
return Results.Json(new { ok = true, melding = "ignored event type" }, statusCode: 200);
}
if (hendelseId is not null) behandlede[hendelseId] = ut;
// 5) Respond fast with 2xx. Heavy post-processing (image import,
// cache purge, sitemap) goes in a background service
// (IHostedService / Channel<T>) AFTER this response. Oron has a 30
// second timeout, and a timeout costs you three extra deliveries.
return Results.Json(ut, statusCode: 200);
}
catch (Exception e)
{
// 5xx: Oron retries after 2 s, 10 s and 30 s. Right for temporary
// errors like a database that is down or a full disk.
Console.Error.WriteLine($"Error while saving: {e.Message}");
return Results.Json(new { feil = "internal error, try again" }, statusCode: 500);
}
});
var port = Environment.GetEnvironmentVariable("PORT") ?? "8805";
app.Run($"http://127.0.0.1:{port}");
return 0;
/// <summary>
/// Verifies "Oron-Signatur" against the raw body.
/// Returns null when everything is fine, otherwise a short reason.
/// </summary>
static string? VerifiserSignatur(string? header, byte[] raaBody, byte[] secretBytes, int toleranse)
{
if (string.IsNullOrEmpty(header)) return "missing Oron-Signatur";
// The format is "t=1786276812,v1=5257a8...". Ignore unknown keys:
// more v-values may come later, and old code should keep working.
string? t = null, v1 = null;
foreach (var del in header.Split(','))
{
var i = del.IndexOf('=');
if (i < 0) continue;
var nokkel = del[..i].Trim();
var verdi = del[(i + 1)..].Trim();
if (nokkel == "t") t = verdi;
else if (nokkel == "v1") v1 = verdi;
}
if (t is null || v1 is null) return "invalid signature format";
if (!long.TryParse(t, NumberStyles.None, CultureInfo.InvariantCulture, out var tSek))
return "invalid timestamp";
// HMAC-SHA256 over t + "." + raw body. We build the bytes ourselves:
// the timestamp, a period, then the body exactly as it arrived.
var prefiks = Encoding.UTF8.GetBytes(t + ".");
var signert = new byte[prefiks.Length + raaBody.Length];
Buffer.BlockCopy(prefiks, 0, signert, 0, prefiks.Length);
Buffer.BlockCopy(raaBody, 0, signert, prefiks.Length, raaBody.Length);
byte[] forventet;
using (var hmac = new HMACSHA256(secretBytes))
{
forventet = hmac.ComputeHash(signert);
}
// Turn the submitted hex string into bytes so we can compare raw bytes
// with FixedTimeEquals. If the hex is invalid or the wrong length, the
// signature is wrong anyway.
byte[] oppgitt;
try
{
oppgitt = Convert.FromHexString(v1);
}
catch (FormatException)
{
return "invalid signature format";
}
// CONSTANT TIME: CryptographicOperations.FixedTimeEquals(), never == or
// SequenceEqual().
//
// A normal comparison stops at the first byte that differs, so the response
// time reveals how many bytes of the signature were right, and an attacker
// who can send many attempts can guess the signature byte by byte by
// measuring response time: ~8000 attempts instead of 2^256. That is a real
// vulnerability, not theory. FixedTimeEquals takes the same time wherever
// the bytes differ. (It returns false on different lengths, which is safe:
// a SHA256 is always 32 bytes long and that is no secret.)
if (!CryptographicOperations.FixedTimeEquals(forventet, oppgitt))
return "signature does not match";
// Replay protection. Checked AFTER the HMAC is approved, so an
// unauthenticated header never steers our logic. An old, correctly signed
// call is exactly what a replay attack looks like.
var alder = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - tSek;
if (alder > toleranse) return "timestamp too old";
// Negative age = timestamp in the future. A little clock skew is normal.
if (alder < -toleranse) return "timestamp in the future";
return null;
}
static string NettstedBasis(JsonObject data)
{
var u = data["nettsted"]?["url"]?.GetValue<string>() ?? "https://example.com";
return u.TrimEnd('/');
}
static string LagSlug(string? tittel)
{
var s = (tittel ?? "").ToLowerInvariant();
var sb = new StringBuilder();
foreach (var c in s.Normalize(NormalizationForm.FormD))
{
if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark) continue;
sb.Append(c is >= 'a' and <= 'z' or >= '0' and <= '9' ? c : '-');
}
var ut = string.Join('-', sb.ToString().Split('-', StringSplitOptions.RemoveEmptyEntries));
if (ut.Length > 80) ut = ut[..80];
return ut.Length == 0 ? "article" : ut;
}
record Innlegg(string Id, string? Tittel, string? Ingress, string? Html, string? Meta);
Run as a real HTTP server against seven signed deliveries.
wp-content/plugins/oron-webhook/oron-webhook.php
<?php
/**
* Plugin Name: Oron Webhook Receiver
* Plugin URI: https://getoron.com/developers
* Description: Receives articles from Oron by webhook and publishes them as posts. Verifies Oron-Signatur (HMAC-SHA256) before anything is stored.
* Version: 1.0.0
* Requires at least: 5.6
* Requires PHP: 7.4
* Author: Oron
* License: GPL-2.0-or-later
*
* ---------------------------------------------------------------------------
* INSTALLATION
* 1. Put the file in wp-content/plugins/oron-webhook/oron-webhook.php
* 2. Activate "Oron Webhook Receiver" under Plugins.
* 3. Set the secret as an environment variable (best), e.g. in wp-config.php:
* putenv('ORON_WEBHOOK_SECRET=whsec_...');
* or in the server config:
* SetEnv ORON_WEBHOOK_SECRET whsec_... (Apache)
* env[ORON_WEBHOOK_SECRET] = whsec_... (php-fpm)
* Or a constant in wp-config.php:
* define('ORON_WEBHOOK_SECRET', 'whsec_...');
* Never hardcoded in this file, and never in git.
* 4. Paste this URL into the Oron dashboard:
* https://your-website.com/wp-json/oron/v1/webhook
* ---------------------------------------------------------------------------
*
* Contract: Oron Webhook v1.
*/
// No direct access to the file.
if (!defined('ABSPATH')) {
exit;
}
/** Time window for replay protection: 5 minutes. */
if (!defined('ORON_TOLERANSE_SEKUNDER')) {
define('ORON_TOLERANSE_SEKUNDER', 300);
}
/**
* Gets the secret from the environment (or a constant in wp-config.php).
*/
function oron_hent_hemmelighet() {
$fra_env = getenv('ORON_WEBHOOK_SECRET');
if (is_string($fra_env) && $fra_env !== '') {
return $fra_env;
}
if (defined('ORON_WEBHOOK_SECRET') && ORON_WEBHOOK_SECRET !== '') {
return ORON_WEBHOOK_SECRET;
}
return '';
}
/**
* Registers the REST endpoint.
*/
function oron_register_routes() {
register_rest_route(
'oron/v1',
'/webhook',
array(
'methods' => 'POST',
'callback' => 'oron_handter_webhook',
/*
* permission_callback is '__return_true' ON PURPOSE: Oron has no
* WordPress user and sends no nonce. Authentication happens with
* the HMAC signature instead, which is the FIRST thing the callback does.
* Without any permission_callback at all, WordPress 5.5+ gives
* a _doing_it_wrong warning, so it is set explicitly here.
*/
'permission_callback' => '__return_true',
)
);
}
add_action('rest_api_init', 'oron_register_routes');
/**
* Verifies "Oron-Signatur" against the raw body.
*
* @param string|null $header The value of Oron-Signatur.
* @param string $raa_body The body exactly as it arrived.
* @param string $secret The secret.
* @return string|null null when everything is fine, otherwise a short reason.
*/
function oron_verifiser_signatur($header, $raa_body, $secret) {
if (!is_string($header) || $header === '') {
return 'missing Oron-Signatur';
}
// The format is "t=1786276812,v1=5257a8...". Ignore unknown keys:
// more v-values may come later, and old code should keep working.
$t = null;
$v1 = null;
foreach (explode(',', $header) as $del) {
$pos = strpos($del, '=');
if ($pos === false) {
continue;
}
$nokkel = trim(substr($del, 0, $pos));
$verdi = trim(substr($del, $pos + 1));
if ($nokkel === 't') {
$t = $verdi;
} elseif ($nokkel === 'v1') {
$v1 = $verdi;
}
}
if ($t === null || $v1 === null) {
return 'invalid signature format';
}
if (!preg_match('/^\d+$/', $t)) {
return 'invalid timestamp';
}
// HMAC-SHA256 over t + "." + raw body. hash_hmac() returns lowercase hex.
$forventet = hash_hmac('sha256', $t . '.' . $raa_body, $secret);
/*
* CONSTANT TIME: hash_equals(), never === or ==.
*
* A normal string comparison in PHP stops at the first byte that differs.
* The response time then reveals how many characters of the signature were
* right, and an attacker who can send many attempts can guess the signature
* character by character by measuring response time: ~1000 attempts instead
* of 16^64. That is a real vulnerability, not theory: here the consequence
* is that anyone could publish posts on the customer's website. hash_equals()
* takes the same time wherever the strings differ.
*/
if (!hash_equals($forventet, strtolower($v1))) {
return 'signature does not match';
}
// Replay protection. Checked AFTER the HMAC is approved. An old,
// correctly signed call is exactly what a replay attack looks like:
// someone captured a real call and is sending it again.
$alder = time() - (int) $t;
if ($alder > ORON_TOLERANSE_SEKUNDER) {
return 'timestamp too old';
}
// Negative age = timestamp in the future. A little clock skew is normal.
if ($alder < -ORON_TOLERANSE_SEKUNDER) {
return 'timestamp in the future';
}
return null;
}
/**
* Finds an existing post from the URL Oron sent.
*
* We store the URL we returned in the post meta field _oron_publisert_url and
* look it up there. That is the key "artikkel.oppdatert" matches on.
*
* @return int Post ID, or 0 if nothing matches.
*/
function oron_finn_innlegg_pa_url($url) {
$treff = get_posts(
array(
'post_type' => 'post',
'post_status' => array('publish', 'draft', 'pending', 'private'),
'numberposts' => 1,
'fields' => 'ids',
'meta_key' => '_oron_publisert_url',
'meta_value' => $url,
'suppress_filters' => false,
)
);
return !empty($treff) ? (int) $treff[0] : 0;
}
/**
* The main handler.
*
* @param WP_REST_Request $request
* @return WP_REST_Response
*/
function oron_handter_webhook($request) {
$secret = oron_hent_hemmelighet();
if ($secret === '') {
// Misconfiguration on our side: 5xx, so Oron retries after the
// secret is set.
return new WP_REST_Response(array('feil' => 'ORON_WEBHOOK_SECRET is missing'), 500);
}
/*
* RAW BODY.
* $request->get_body() gives the bytes exactly as they arrived.
* NEVER use $request->get_json_params() or get_params() for the
* signature: the JSON has already been parsed, and re-serializing gives a
* different byte stream (key order, whitespace, escaping of non-ASCII and
* emoji) than the one Oron signed. The HMAC will never match.
*/
$raa_body = $request->get_body();
if (!is_string($raa_body)) {
$raa_body = '';
}
// 1) SIGNATURE FIRST, before we interpret a single byte of the content.
$grunn = oron_verifiser_signatur($request->get_header('Oron-Signatur'), $raa_body, $secret);
if ($grunn !== null) {
error_log('Oron webhook rejected: ' . $grunn);
// 401 = not authenticated. 4xx means "don't retry", which is right:
// a retry with the wrong secret gives the same answer.
return new WP_REST_Response(array('feil' => 'invalid signature'), 401);
}
// 2) Now, and only now, can we parse the body.
$data = json_decode($raa_body, true);
if (!is_array($data)) {
// 400: broken body. A retry sends the same broken body.
return new WP_REST_Response(array('feil' => 'invalid JSON'), 400);
}
$hendelse = isset($data['hendelse']) ? (string) $data['hendelse'] : '';
$hendelse_id = isset($data['hendelse_id'])
? (string) $data['hendelse_id']
: (string) $request->get_header('Oron-Hendelse-Id');
/*
* 3) IDEMPOTENCY on hendelse_id.
* Oron delivers up to 4 times (right away, +2 s, +10 s, +30 s) on 5xx,
* 429, timeout and network errors, with the SAME hendelse_id. If you
* don't respond within 30 seconds, exactly the same event comes again
* even if the post is already created. Without dedupe, the customer
* gets four identical posts.
*
* Below we use a transient (24 hours) because it works in any
* WordPress install without migrations.
*
* >>> WANT IT WATERTIGHT? Do this instead: <<<
* Create a table on activation, with UNIQUE on hendelse_id:
* global $wpdb;
* $wpdb->query("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}oron_hendelser (
* hendelse_id VARCHAR(64) NOT NULL PRIMARY KEY,
* svar TEXT,
* opprettet DATETIME NOT NULL
* )");
* and INSERT right before you create the post. If the INSERT fails on
* a duplicate key, the event is already handled: fetch the row and
* return the stored response with 200. A transient is not
* atomic: two simultaneous attempts can both see "not handled"
* before either of them gets to write.
*/
if ($hendelse_id !== '') {
$lagret = get_transient('oron_hendelse_' . md5($hendelse_id));
if (is_array($lagret)) {
// Same response as the first time: Oron should get the real URL
// even when it was only our response that got lost.
return new WP_REST_Response($lagret, 200);
}
}
// 4) test.hendelse: the customer clicked "Send test event" in the dashboard.
// Real signature, made-up article. NEVER publish it, but acknowledge
// it with 2xx so the test in the dashboard turns green.
if ($hendelse === 'test.hendelse') {
return new WP_REST_Response(
array('ok' => true, 'melding' => 'test event received, not published'),
200
);
}
$tittel = isset($data['tittel']) ? (string) $data['tittel'] : '';
$ingress = isset($data['ingress']) ? (string) $data['ingress'] : '';
$html = isset($data['html']) ? (string) $data['html'] : '';
$meta = isset($data['meta']) ? (string) $data['meta'] : '';
if ($hendelse === 'artikkel.publisert') {
// NEW article.
// wp_slash() because wp_insert_post() expects "slashed" data and
// runs wp_unslash() internally. Without it, backslashes disappear.
$post_id = wp_insert_post(
array(
'post_type' => 'post',
'post_status' => 'publish', // want to approve by hand? use 'draft'
'post_title' => wp_slash(sanitize_text_field($tittel)),
// The HTML comes finished from Oron and is signature-verified.
// wp_kses_post() keeps everything WordPress itself allows in posts
// and strips script/iframe etc.: cheap extra safety.
'post_content' => wp_slash(wp_kses_post($html)),
'post_excerpt' => wp_slash(sanitize_text_field($ingress)),
),
true // return WP_Error instead of 0 on failure
);
if (is_wp_error($post_id) || !$post_id) {
// 5xx: Oron retries after 2 s, 10 s and 30 s.
error_log('Oron: could not create post');
return new WP_REST_Response(array('feil' => 'could not create post'), 500);
}
$url = get_permalink($post_id);
// We store the URL on the post, because that is what Oron sends back
// as the top-level `url` on "artikkel.oppdatert".
update_post_meta($post_id, '_oron_publisert_url', $url);
update_post_meta($post_id, '_oron_artikkel_id', isset($data['artikkel']['id']) ? (int) $data['artikkel']['id'] : 0);
// Meta description. Using Yoast or Rank Math? Write to
// '_yoast_wpseo_metadesc' or 'rank_math_description' instead.
update_post_meta($post_id, '_oron_meta_beskrivelse', $meta);
// Return the REAL permalink. Without it, Oron stores a guessed
// "website/slug", which rarely matches the WordPress permalink
// structure (/2026/08/title/, /blog/title/ ...), and reports and
// rank tracking would point to a URL that doesn't exist.
// `id` is stored as ekstern_id and comes back on updates.
$ut = array('url' => $url, 'id' => (string) $post_id);
} elseif ($hendelse === 'artikkel.oppdatert') {
// UPDATE IN PLACE: don't create a new post.
$url = isset($data['url']) ? (string) $data['url'] : '';
if ($url === '') {
return new WP_REST_Response(array('feil' => 'missing url on update'), 400);
}
// First: try ekstern_id (the post ID we returned last time). Then the URL.
$post_id = 0;
if (!empty($data['artikkel']['ekstern_id'])) {
$kandidat = (int) $data['artikkel']['ekstern_id'];
if ($kandidat > 0 && get_post_status($kandidat) !== false) {
$post_id = $kandidat;
}
}
if (!$post_id) {
$post_id = oron_finn_innlegg_pa_url($url);
}
if ($post_id) {
$resultat = wp_update_post(
array(
'ID' => $post_id,
'post_title' => wp_slash(sanitize_text_field($tittel)),
'post_content' => wp_slash(wp_kses_post($html)),
'post_excerpt' => wp_slash(sanitize_text_field($ingress)),
),
true
);
if (is_wp_error($resultat)) {
error_log('Oron: could not update post ' . $post_id);
return new WP_REST_Response(array('feil' => 'could not update post'), 500);
}
} else {
// If we don't recognize the URL (the post was deleted, or the
// connection was made before this plugin), we create rather
// than lose the article.
$post_id = wp_insert_post(
array(
'post_type' => 'post',
'post_status' => 'publish',
'post_title' => wp_slash(sanitize_text_field($tittel)),
'post_content' => wp_slash(wp_kses_post($html)),
'post_excerpt' => wp_slash(sanitize_text_field($ingress)),
),
true
);
if (is_wp_error($post_id) || !$post_id) {
error_log('Oron: could not create post on update');
return new WP_REST_Response(array('feil' => 'could not create post'), 500);
}
}
update_post_meta($post_id, '_oron_publisert_url', $url);
update_post_meta($post_id, '_oron_meta_beskrivelse', $meta);
// We return the URL Oron already knows, so the link holds.
$ut = array('url' => $url, 'id' => (string) $post_id);
} else {
// Unknown event type: 200, not an error. New types should not cause
// four failed delivery attempts at Oron.
return new WP_REST_Response(array('ok' => true, 'melding' => 'ignored event type'), 200);
}
if ($hendelse_id !== '') {
// 24 hours is plenty: all four attempts happen within ~42 seconds.
set_transient('oron_hendelse_' . md5($hendelse_id), $ut, DAY_IN_SECONDS);
}
// 5) Respond fast with 2xx. Heavy post-processing (image import with
// media_sideload_image, cache purge, sitemap) goes in
// wp_schedule_single_event(time(), ...) AFTER this response. Oron has
// a 30 second timeout, and a timeout costs you three extra deliveries.
return new WP_REST_Response($ut, 200);
}
Run as a file, with rest_api_init and permission_callback. A real wp_insert_post against MySQL has not been tested.
The code examples go here. Need a language that isn't on the list? Email hello@getoron.com and we will write it for you.
Troubleshooting#
The status codes in the log in your dashboard are your responses to us, not ours to you. If you see 401 there, it was your endpoint that answered 401.
| Symptom | Likely cause | Fix |
|---|---|---|
| The signature never matches | You are signing re-serialized JSON, not the raw body. | Keep the bytes before the body parser. Express: express.json({verify}) or express.raw(). Next.js App Router: await req.text(). PHP: file_get_contents('php://input'). Django: request.body. |
| Matches locally, not in production | A proxy, WAF or gzip middleware changes the body on the way. | Log the raw body length in both places and compare. Turn off body rewriting for this route. |
é ü ’ turn into é ü ’ |
The body is read as Latin-1, or decoded twice. | Read bytes and decode as UTF-8 once. Check that the database column is utf8mb4 and that your response is UTF-8 too. |
| We log 401 or 403 | The route requires a login, API key or CSRF token. | We send no cookie and no bearer token. The identity is in the signature. Exempt the route from auth and CSRF. Laravel: add the URI to $except in the CSRF middleware. |
| We log 405 | The route only accepts GET. | We always use POST. |
| We log 404 | A typo in the URL, or a redirect in front of the endpoint. | Paste the final address, with or without www, exactly as it actually responds. A redirect can stop the body from arriving. |
| We log a timeout | You do heavy work before you respond. | Verify, queue, respond 200. The limit is 30 seconds. |
| The same article twice | Your response didn't reach us, so we retried. | Make your receiver idempotent on hendelse_id. See Idempotency. |
| The timestamp is "too old" | Your server's clock is drifting. | Turn on NTP. Keep the 5-minute window; don't widen it to get around the problem. |
| Nothing arrives at all | The address is http://, points to an internal IP, or isn't reachable from outside. |
Public https:// addresses only. Test with "Send test event": the response there shows exactly what we got. |
| Oron shows the wrong URL for the article | You didn't return a url, so we guessed. |
Respond with {"url": "…"}. See Your response. |
| The HTML looks odd | It gets escaped, or run through a strict sanitizer. | html is finished HTML and should be inserted as HTML. Today we only send h2, p and br. If your sanitizer lets through at least h2, h3, p, br, ul, ol, li, a, strong and em, it will handle later versions too. |
Questions and answers#
How often does something arrive?
On the Growth plan you get 13 articles a month (about 3 a week), each sent around 05:00 UTC. Updates to existing articles come on top of that, whenever we rewrite something.
Can I use http?
No. https:// only. Addresses that point to internal or private networks are rejected.
What happens if I don't check the signature?
Everything works as before: we send the header anyway, and you can ignore it. But then anyone who knows or guesses your URL can publish content on your website. Check it.
Is the field order in the JSON guaranteed?
No. Use a JSON parser, not text matching. And ignore fields you don't recognize: new ones may come.
Can I get the article sent to several addresses?
No. One website has one webhook address. If you need to distribute it further, do that on your side after you receive the event.
Do you have fixed IP addresses I can allow through my firewall?
No, we have no fixed IPs to give you. Use the signature to verify that the call comes from us. It is safer than an IP list anyway.
Are images included?
artikkel.bilde_url is often null. Build your receiver so it handles articles without an image.
What happens if my server is down when the article is sent?
We try four times, with 2, 10 and 30 second pauses in between. If all four fail, the background job tries again later the same day with the same hendelse_id. The article stays safe with us either way. If it never arrives, email us and we will help you get it out.
Can I change the secret?
Yes, you rotate it in the dashboard. The old one stops working right away, so swap it in your receiver at the same time.
I already built against the old payload. Do I need to do anything?
No. tittel, ingress, html and meta are still at the top level and will never be removed. Everything else was added alongside them. The only things we recommend adding are signature verification and {"url": "…"} in your response.
Something missing?#
If something is unclear, wrong, or your language is missing from the examples, email hello@getoron.com. We will answer, and we will update this page.