orangecheck · Webhooks
Every event your project records is delivered to your registered endpoints as its receipt, signed with orangecheck's Ed25519 webhook key. Verify the signature against the raw request body — frameworks that re-serialize before your handler will produce a different byte sequence and the signature will not validate.
Reception · Node + Express
import { oc } from '@orangecheck/me-client';
import express from 'express';
const app = express();
app.use(express.text({ type: 'application/json' })); // raw body!
app.post('/api/oc/webhook', async (req, res) => {
const result = await oc.webhook.verify(
req.body,
req.header('OC-Signature'),
req.header('OC-Key-Id')
);
if (!result.ok) return res.status(401).end(result.reason);
const envelope = JSON.parse(req.body);
// envelope.kind === 'oc-billable-event'
// envelope.subtype === 'session_creation' | 'payment_authorization' | …
// envelope.id is content-addressed — idempotent
await onOcEvent(envelope);
res.status(200).end();
});
Reception · Rust + Axum
use axum::{extract::State, http::StatusCode, response::IntoResponse};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
async fn webhook(
State(pub_key): State<VerifyingKey>,
headers: axum::http::HeaderMap,
body: bytes::Bytes,
) -> impl IntoResponse {
let sig_hex = headers.get("OC-Signature").and_then(|v| v.to_str().ok()).unwrap_or("");
let sig_bytes = hex::decode(sig_hex).unwrap_or_default();
let Ok(sig) = Signature::from_slice(&sig_bytes) else {
return StatusCode::UNAUTHORIZED;
};
if pub_key.verify(&body, &sig).is_err() {
return StatusCode::UNAUTHORIZED;
}
let envelope: serde_json::Value = serde_json::from_slice(&body).unwrap();
process_event(envelope).await;
StatusCode::OK
}
Headers OC sends
| Header | Meaning |
|---|---|
OC-Signature | Ed25519 signature, hex-encoded, computed over the raw body |
OC-Key-Id | kid of the OC public JWK that signed the event |
OC-Envelope-Id | Idempotency key — same envelope retried after 2xx ack is your problem to dedupe |
OC-Subtype | Event subtype, copied for routing convenience |
OC-Class | A · B · C: One-time, Per action, Per session |
OC-Delivery-Attempt | 1-based retry counter |
Content-Type | application/json |
Delivery semantics
| Guarantee | At-least-once. Idempotent on envelope.id. |
| Retry schedule | Jittered exponential: 0s · 30s · 2m · 10m · 1h · 6h · 24h. |
| Ack | Any 2xx. Anything else triggers retry. |
| Mute | After 24h of failure the endpoint is muted. Envelopes still archive on /api/envelope/[id]. |
The raw-body trap
Verify against the raw bytes, not the parsed JSON.
JSON.parse(body) then JSON.stringify(value) produces a different byte
sequence — different key order, different whitespace, different number
formatting. The signature is computed over the original bytes; re-serialized
bytes will not match.
| Framework | The fix |
|---|---|
| Express | app.use(express.text({ type: '*/*' })) — accept body as a raw string. |
| Next.js Pages API | export const config = { api: { bodyParser: false } } — disable body parsing, then read the stream into a Buffer. |
| Fastify | Use a preParsing hook to capture the raw body before parsing. |
| Hono | c.req.text() to get the raw string. |
| Rust + Axum | body: bytes::Bytes extractor, never Json<…>. |
Test with a real delivery
On your project, Send a test event fires a sample event under your project key. It is stored with your project's events and delivered to every webhook you registered, signed like any other delivery, so your receiver should accept it. Deliveries that run out of retries are listed with your webhooks, where you can requeue them.
Handler patterns we recommend
// Idempotency · keep a small cache of recently seen envelope ids.
const seen = new LRU<string, true>({ max: 10_000 });
app.post('/api/oc/webhook', async (req, res) => {
const result = await oc.webhook.verify(
req.body,
req.header('OC-Signature'),
req.header('OC-Key-Id')
);
if (!result.ok) return res.status(401).end(result.reason);
const envelope = JSON.parse(req.body);
if (seen.has(envelope.id)) return res.status(200).end(); // already processed
seen.set(envelope.id, true);
try {
await onOcEvent(envelope);
res.status(200).end();
} catch (err) {
// Don't 5xx unless you actually want the retry; otherwise log + 200.
console.error(err);
res.status(500).end();
}
});