Skip to main content
The Web Crypto API ships with every modern runtime — Edge, Node 18+, every modern browser. Single dependency-free implementation. Full source (verbatim): docs/examples/webhook-receivers/typescript-cloudflare-worker/worker.ts

worker.ts

const DEFAULT_PEGANA_PUB_KEY_B64 = "gAKAhNG4BLCF0xm/gCcDb0OcP6cxzt1IfXkmkzyVYVo=";
const REPLAY_WINDOW_SEC = 300;

interface Env {
  /** Optional override — set via `wrangler secret put PEGANA_PUB_KEY_B64`. */
  PEGANA_PUB_KEY_B64?: string;
}

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    if (req.method !== "POST") {
      return new Response("POST only", { status: 405 });
    }
    const sigHeader = req.headers.get("x-pegana-signature");
    const ts = req.headers.get("x-pegana-timestamp");
    if (!sigHeader || !ts) {
      return new Response("missing signature headers", { status: 401 });
    }

    const tsNum = Number(ts);
    if (!Number.isFinite(tsNum)) {
      return new Response("bad timestamp", { status: 401 });
    }
    const nowSec = Math.floor(Date.now() / 1000);
    if (Math.abs(nowSec - tsNum) > REPLAY_WINDOW_SEC) {
      return new Response("stale", { status: 401 });
    }

    const sigB64 = sigHeader.replace(/^ed25519:/, "");
    const sigBytes = Uint8Array.from(atob(sigB64), (c) => c.charCodeAt(0));
    if (sigBytes.length !== 64) {
      return new Response("bad signature length", { status: 401 });
    }
    const pubB64 = env.PEGANA_PUB_KEY_B64 ?? DEFAULT_PEGANA_PUB_KEY_B64;
    const pubBytes = Uint8Array.from(atob(pubB64), (c) => c.charCodeAt(0));

    const body = await req.text();
    const input = new TextEncoder().encode(`${ts}.${body}`);

    const key = await crypto.subtle.importKey(
      "raw",
      pubBytes,
      { name: "Ed25519" },
      false,
      ["verify"],
    );
    const ok = await crypto.subtle.verify("Ed25519", key, sigBytes, input);
    if (!ok) return new Response("bad signature", { status: 401 });

    const event = JSON.parse(body) as {
      asset: string;
      from_state: string;
      to_state: string;
      discount_bps: number;
      ts: string;
      alert_id: string;
    };

    // Your business logic here. Examples:
    //   await env.KV.put(`pegana:seen:${event.alert_id}`, "1", { expirationTtl: 86400 });
    //   await fetch(SLACK_WEBHOOK, { method: "POST", body: JSON.stringify({ text: `${event.asset} → ${event.to_state}` }) });
    console.log("verified", event);

    return new Response("ok");
  },
};

Deploy on Cloudflare Workers

npm create cloudflare@latest -- pegana-hook
cd pegana-hook
# Replace src/index.ts (or worker.ts) with the above
wrangler deploy
To pin a different key (during a rotation):
wrangler secret put PEGANA_PUB_KEY_B64
# paste the new base64 key when prompted
The deployed URL becomes https://pegana-hook.<account>.workers.dev — register it in pegana.xyz/account/webhooks.

Deploy on Vercel Edge

Same logic in app/api/pegana-hook/route.ts:
export const runtime = "edge";

const PEGANA_PUB_KEY_B64 = process.env.PEGANA_PUB_KEY_B64
  ?? "gAKAhNG4BLCF0xm/gCcDb0OcP6cxzt1IfXkmkzyVYVo=";
const REPLAY_WINDOW_SEC = 300;

export async function POST(req: Request) {
  const sigHeader = req.headers.get("x-pegana-signature");
  const ts = req.headers.get("x-pegana-timestamp");
  if (!sigHeader || !ts) return new Response("missing", { status: 401 });

  // ... (same verify as worker.ts)

  return new Response("ok");
}

Node 18+ (Express, Fastify)

Same verification. Make sure you read the raw body before any JSON middleware:
import express from "express";
const app = express();

app.post(
  "/pegana-hook",
  express.raw({ type: "*/*" }),    // raw bytes, no JSON parsing
  async (req, res) => {
    const body = (req.body as Buffer).toString("utf-8");
    const ts = req.header("x-pegana-timestamp");
    const sig = req.header("x-pegana-signature");

    // ... (same verify as worker.ts, using the raw body string)
  }
);

Production checklist

  • Persist alert_id in a real store (Cloudflare KV, Redis, your DB)
  • Pin PEGANA_PUB_KEY_B64 via secrets, not in source — easier rotation
  • ACK with 200 (or 202) within ~100ms; queue real work to a background runner
  • Log failed verifications with timestamp + remote IP for audit
  • Poll /v1/me/alerts as a safety net (catch deliveries that exhausted retries)
  • Set Content-Type on your response — some load balancers strip empty bodies

Why Web Crypto and not @noble/ed25519

The Web Crypto API is standardized, audited, and ships with every modern runtime (Workers since 2023 via Secure Curves; Node since 18 via webcrypto). @noble/ed25519 is excellent and you may need it for older Node versions (<18) — but for new code, Web Crypto is one less dependency.

Next

Rust example

axum + ed25519-dalek strict mode.

Retry policy

Failure modes, backoff, replay.