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
Via SDK (recommended)
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 ONCE — store 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
| Event | Trigger |
|---|---|
notification.delivered | Message handed off to the provider (SES / Telegram / SMS). |
notification.bounced | Email bounced (hard or soft). |
notification.failed | Delivery failed after all retry attempts. |
receipt.confirmed | ZK proof of delivery written to Solana (receipt_tx populated). |
quota.exceeded | Protocol 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...Via SDK (recommended)
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.