Webhook receiver (Node.js)

    This guide shows a simple way to receive Coinset webhooks locally and test your endpoint from the public internet using a tunnel.

    What you’ll build

    • A single-file Node.js webhook receiver that:
      • accepts Coinset deliveries (POST /webhook)
      • handles verification (coinset.webhook.verify)
      • verifies signatures (X-Coinset-Signature)
      • logs delivery ids and event ids
    • A tunnel (LocalTunnel, Cloudflare Tunnel, or ngrok) that gives you a public HTTPS URL.

    1) Create a minimal receiver

    Create a file named receiver.mjs:

    import http from "node:http";
    import crypto from "node:crypto";
    
    const PORT = Number(process.env.PORT || 8787);
    const SECRET = process.env.COINSET_WEBHOOK_SECRET || "";
    
    function timingSafeEqualHex(a, b) {
      const aa = Buffer.from(String(a || ""), "hex");
      const bb = Buffer.from(String(b || ""), "hex");
      return aa.length === bb.length && crypto.timingSafeEqual(aa, bb);
    }
    
    function verifySignature({ secret, timestampMs, rawBody, signatureHex }) {
      if (!secret) return false;
      const mac = crypto.createHmac("sha256", secret);
      mac.update(`${timestampMs}.`);
      mac.update(rawBody); // Buffer
      const expected = mac.digest("hex");
      return timingSafeEqualHex(expected, signatureHex);
    }
    
    const server = http.createServer(async (req, res) => {
      if (req.method !== "POST" || req.url !== "/webhook") {
        res.writeHead(404);
        res.end("not found");
        return;
      }
    
      // Read raw body bytes (required for signature verification).
      const chunks = [];
      for await (const chunk of req) chunks.push(chunk);
      const rawBody = Buffer.concat(chunks);
    
      const webhookId = req.headers["x-coinset-webhook-id"];
      const deliveryId = req.headers["x-coinset-delivery-id"];
      const eventId = req.headers["x-coinset-event-id"];
      const timestampMs = req.headers["x-coinset-timestamp-ms"];
      const sigHex = req.headers["x-coinset-signature"];
    
      // Verify signature (recommended).
      if (SECRET) {
        const ok = verifySignature({
          secret: SECRET,
          timestampMs,
          rawBody,
          signatureHex: sigHex,
        });
        if (!ok) {
          res.writeHead(401, { "content-type": "text/plain" });
          res.end("bad signature");
          return;
        }
      }
    
      let body;
      try {
        body = JSON.parse(rawBody.toString("utf8"));
      } catch {
        res.writeHead(400, { "content-type": "text/plain" });
        res.end("bad json");
        return;
      }
    
      console.log("coinset webhook", {
        webhookId,
        deliveryId,
        eventId,
        type: body?.type,
      });
    
      // Verification handshake: echo challenge (optional, but nice).
      if (body?.type === "coinset.webhook.verify" && body?.challenge) {
        res.writeHead(200, { "content-type": "application/json" });
        res.end(JSON.stringify({ ok: true, challenge: body.challenge }));
        return;
      }
    
      // Normal event delivery.
      res.writeHead(200, { "content-type": "application/json" });
      res.end(JSON.stringify({ ok: true }));
    });
    
    server.listen(PORT, () => {
      console.log(`listening on http://localhost:${PORT}/webhook`);
      if (!SECRET)
        console.log("NOTE: COINSET_WEBHOOK_SECRET is not set (signature verification disabled).");
    });
    

    Run it:

    export COINSET_WEBHOOK_SECRET="paste-your-webhook-secret-here"
    node receiver.mjs
    

    2) Expose it with a tunnel

    Coinset requires a publicly reachable HTTPS URL (except for localhost during local dev).

    Option A: LocalTunnel (recommended)

    LocalTunnel is free and quick for testing.

    npx localtunnel --port 8787
    

    It will print a public https://....loca.lt URL. Your webhook URL becomes:

    • https://<your-subdomain>.loca.lt/webhook

    Option B: Cloudflare Tunnel

    Install cloudflared, then run:

    cloudflared tunnel --url http://localhost:8787
    

    It will print a public https://...trycloudflare.com URL. Your webhook URL becomes:

    • https://<your-subdomain>.trycloudflare.com/webhook

    Option C: ngrok

    ngrok http 8787
    

    Use the https://...ngrok-free.app/webhook URL.

    3) Register the webhook in Coinset

    In the Coinset Developer UI:

    1. Create a webhook pointing at your tunnel URL (ending in /webhook).
    2. Coinset will send a verification request.
    3. Once it shows as active, click Test to verify end-to-end delivery.

    Common gotchas

    • You must verify the signature using the raw body bytes, not a re-serialized JSON object.
    • Use X-Coinset-Event-Id for idempotency (deliveries are at-least-once).
    • Return 2xx quickly; do slow work async (queue / background job).