live · mainnetme · ochk · io
federation-custodied · self-custody-ready
§ sign in to manage projectschecking your oc identity…sign in →
§ developer · webhooks

receive every envelope your project signed.

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.

§ sample data

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.

create real project →
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
https://billing.zaprite.example/oc-events
wh_live_zap_b21 · subscribes to payment_authorization, kyc_tier_upgrade, account_creation, pledge_resolution
healthy
last delivery
5h ago
last status
200
p50 latency
132ms
30d success
4,018 / 4,018
https://staging.zaprite.example/api/oc/webhook
wh_test_zap_0c7 · subscribes to every subtype
degraded
last delivery
5h ago
last status
502
p50 latency
320ms
30d success
612 / 659
§ verifying signatures · node
// 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();
});
§ verifying signatures · rust
// 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
}
§ delivery semantics
  • > at-least-once · idempotent on envelope.id
  • > exponential backoff, jittered: 0s · 30s · 2m · 10m · 1h · 6h · 24h
  • > 2xx = ack, anything else = retry
  • > after 24h of failure the endpoint is muted; envelopes still archive on /api/envelope/[id]
§ headers we send
  • OC-Signature · ed25519 hex over raw body
  • OC-Key-Id · matches kid in JWKS
  • OC-Envelope-Id · idempotency key
  • OC-Subtype · routing convenience copy
  • OC-Class · A · B · C
  • OC-Delivery-Attempt · 1-based retry counter
  • Content-Type · application/json
§ raw-body warning

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 } }.