live · mainnetoc · docs
specs · api · guides
docs / webhooks

OC Me · Webhooks

Every billable envelope your project signs is delivered to your registered endpoints, signed with OC's federation Ed25519 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

HeaderMeaning
OC-SignatureEd25519 signature, hex-encoded, computed over the raw body
OC-Key-Idkid of the OC public JWK that signed the event
OC-Envelope-IdIdempotency key — same envelope retried after 2xx ack is your problem to dedupe
OC-SubtypeEvent subtype, copied for routing convenience
OC-ClassA · B · C
OC-Delivery-Attempt1-based retry counter
Content-Typeapplication/json

Delivery semantics

GuaranteeAt-least-once. Idempotent on envelope.id.
Retry scheduleJittered exponential: 0s · 30s · 2m · 10m · 1h · 6h · 24h.
AckAny 2xx. Anything else triggers retry.
MuteAfter 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.

FrameworkThe fix
Expressapp.use(express.text({ type: '*/*' })) — accept body as a raw string.
Next.js Pages APIexport const config = { api: { bodyParser: false } } — disable body parsing, then read the stream into a Buffer.
FastifyUse a preParsing hook to capture the raw body before parsing.
Honoc.req.text() to get the raw string.
Rust + Axumbody: bytes::Bytes extractor, never Json<…>.

Test fire from the dashboard

me.ochk.io/developer/webhooks has a "test fire" button per registered endpoint. It POSTs a synthetic envelope with a placeholder signature so you can verify wiring before a real event lands. Receivers verifying with @noble/curves should reject the test signature (the placeholder is all zeros) — that's the correct behavior. Production envelopes carry valid sigs.

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();
    }
});