Herald LogoHerald Docs

Webhooks

Receive real-time push notifications of delivery results and ZK-receipt events.

Herald sends outbound webhooks to your backend when a notification is delivered, bounced, or fails — and when ZK receipts land on-chain.

Registering an Endpoint

import { Herald } from "@herald-protocol/sdk";

const herald = new Herald({ apiKey: process.env.HERALD_API_KEY! });

const webhook = await herald.createWebhook({
  url: "https://myprotocol.com/webhooks/herald",
  events: ["notification.delivered", "notification.failed", "notification.bounced"],
});

// The secret is shown ONCEstore it immediately
console.log(webhook.secret); // 'whsec_xxx...'
console.log(webhook.id);     // 'wh_01HX...'

Via REST API

curl -X POST https://api.useherald.xyz/v1/webhooks \
  -H "Authorization: Bearer hrld_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://myprotocol.com/webhooks/herald",
    "events": ["notification.delivered", "notification.failed", "notification.bounced"]
  }'

Managing Webhooks

// List all registered endpoints
const webhooks = await herald.listWebhooks();

// Update events or URL
await herald.updateWebhook(webhook.id, {
  events: ["notification.delivered"],
  isActive: true,
});

// Send a test event to verify connectivity
await herald.testWebhook(webhook.id);

// Remove an endpoint
await herald.deleteWebhook(webhook.id);

Event Types

EventTrigger
notification.deliveredMessage handed off to the provider (SES / Telegram / SMS).
notification.bouncedEmail bounced (hard or soft).
notification.failedDelivery failed after all retry attempts.
receipt.confirmedZK proof of delivery written to Solana (receipt_tx populated).
quota.exceededProtocol exceeded its monthly messaging quota.

Payload Shape

All webhook events share the same envelope:

{
  "id": "evt_01HX4...",
  "type": "notification.delivered",
  "created": 1716000000,
  "data": {
    "notification_id": "01HWXYZ...",
    "wallet": "7xR4mKp2nQ...",
    "channel": "telegram",
    "delivered_at": "2026-05-17T12:34:56Z"
  }
}

For receipt.confirmed events, data also includes:

{
  "receipt_tx": "5xK9mPqR...",
  "confirmed_at": "2026-05-17T12:35:10Z"
}

Signature Verification

Every webhook request includes an x-herald-signature header:

x-herald-signature: t=1716000000,v1=9c3f...
import { Herald } from "@herald-protocol/sdk";

// In your webhook handler:
const isValid = await Herald.verifyWebhookSignature(
  rawBody,                                        // string | Buffer
  request.headers["x-herald-signature"],          // 't=...,v1=...'
  process.env.HERALD_WEBHOOK_SECRET!,
);

if (!isValid) return new Response("Unauthorized", { status: 401 });

Manual verification

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyWebhookSignature(
  payload: string,
  signatureHeader: string,
  secret: string
): boolean {
  const parts = signatureHeader.split(",");
  const timestamp = parts[0]?.split("=")[1] ?? "";
  const signature = parts[1]?.split("=")[1] ?? "";

  const signedPayload = `${timestamp}.${payload}`;
  const expected = createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");

  return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

Full Next.js Handler Example

// app/api/webhooks/herald/route.ts
import { NextResponse } from "next/server";
import { Herald } from "@herald-protocol/sdk";

export async function POST(request: Request) {
  const payload = await request.text();
  const sig = request.headers.get("x-herald-signature") ?? "";

  const isValid = await Herald.verifyWebhookSignature(
    payload,
    sig,
    process.env.HERALD_WEBHOOK_SECRET!
  );

  if (!isValid) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  const event = JSON.parse(payload);

  switch (event.type) {
    case "notification.delivered":
      console.log(`Delivered: ${event.data.notification_id} via ${event.data.channel}`);
      break;
    case "notification.bounced":
      console.warn(`Bounced: ${event.data.notification_id}`);
      break;
    case "notification.failed":
      console.error(`Failed: ${event.data.notification_id}`);
      break;
    case "receipt.confirmed":
      console.log(`ZK receipt on-chain: ${event.data.receipt_tx}`);
      break;
    case "quota.exceeded":
      console.error("Monthly quota exceeded — upgrade your tier!");
      break;
  }

  return NextResponse.json({ received: true });
}

Note: Your webhook secret (whsec_xxx) is returned when you register an endpoint and never shown again. Store it in an environment variable and never expose it in client-side code.

On this page