https://zaprite.example/api/oc/webhook
wh_live_zap_44a · subscribes to every subtype
healthy
- last delivery
- 4h ago
- last status
- 200
- p50 latency
- 84ms
- 30d success
- 14,412 / 14,415
every billable event your project generates is delivered to your endpoints as a canonical envelope, signed by OC's federation key. retries are exponential up to 24h. always verify the signature against ochk.io/.well-known/jwks.json.
this page renders representative integrator state so you can see what the dashboard looks like without signing up. it is not your project. to manage a real project, go to /developer and create one.
// reception · server-side
import express from 'express';
import { ed25519 } from '@noble/curves/ed25519';
import { sha256 } from '@noble/hashes/sha256';
const OC_PUB_JWK = await fetch('https://ochk.io/.well-known/jwks.json')
.then((r) => r.json())
.then((d) => d.keys.find((k) => k.crv === 'Ed25519'));
const app = express();
app.use(express.text({ type: 'application/json' })); // raw body!
app.post('/api/oc/webhook', (req, res) => {
const sigHex = req.header('OC-Signature'); // hex Ed25519 sig
const kid = req.header('OC-Key-Id'); // matches OC's JWK kid
if (kid !== OC_PUB_JWK.kid) return res.status(401).end();
const hash = sha256(new TextEncoder().encode(req.body));
const sig = Uint8Array.from(Buffer.from(sigHex, 'hex'));
const pub = decodeBase64Url(OC_PUB_JWK.x);
if (!ed25519.verify(sig, hash, pub)) return res.status(401).end();
const event = JSON.parse(req.body); // canonical envelope
// event.kind === 'oc-billable-event'
// event.subtype === 'session_creation' | 'payment_authorization' | …
// event.gross_fee_sats / .user_earned_sats / .platform_fee_sats / .site_rebate_sats
// event.id is the content-addressed envelope id — idempotent
await onOcEvent(event);
res.status(200).end();
});// reception · server-side (rust · axum + ed25519-dalek)
use axum::{extract::State, http::StatusCode, response::IntoResponse, routing::post, Router};
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
}envelope.id/api/envelope/[id]Verify against the raw request body, not the parsed JSON. Frameworks that re-serialize before your handler will produce a different byte sequence and the signature will not validate.
In Express that means express.text({ type: '*/*' }); in Next.js API routes set config = { api: { bodyParser: false } }.