Guides
Delivery webhooks
Receive signed Ray delivery webhooks for delivered, failed and completed sends, manage subscriptions, and verify X-Ray-Signature in TypeScript.
Delivery webhooks are HTTPS callbacks Ray POSTs to your server when a delivery reaches a final state. Subscribe an endpoint with the API (or in the dashboard under Webhooks), then verify each request's X-Ray-Signature header. Management requests go to https://ray-api.gege.mn with Authorization: Bearer $RAY_API_KEY.
Delivery webhooks are available on the Pro plan and above (see https://ray.gege.mn/pricing). Creating one on a lower plan returns 403 forbidden (delivery webhooks require the Pro plan or higher), and existing webhooks stop receiving events if the workspace moves to a plan without the feature.
This page is about events Ray sends to you. To deliver notifications to your own HTTPS endpoint as a channel, see the Webhook channel.
Events
| Event | Fires when |
|---|---|
notification.delivered |
A delivery row became delivered (the provider accepted the message). |
notification.failed_terminal |
A delivery row became failed_terminal (permanent failure or retries exhausted). |
send.completed |
Every row of a send is final. Fires once per send, with status totals. |
Which events fire
| Send | notification.* |
send.completed |
|---|---|---|
Single recipient (or targets[] with exactly one entry) |
One per row | Once |
Fan-out targets[] with 2 or more entries |
None | Once, after the last row settles |
Multi-channel deliveries[] |
One per channel row | Once |
| Feed-only | None | Once, immediately |
| Every recipient suppressed | None | Once, immediately |
| Test send | None | None |
Suppressed rows never produce notification.* events; they are counted in send.completed totals. Subscribe to send.completed for bulk sends so 1000 recipients mean one callback, not 1000.
Payloads
Ray POSTs a JSON body. notification.delivered and notification.failed_terminal:
{
"event": "notification.delivered",
"occurred_at": "2026-09-14T09:30:01.502Z",
"send_id": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d",
"notification_log_id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
"channel_config_id": "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
"template_id": "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
"external_user_id": "user_123",
"provider_message_id": "0100019252a1b2c3-5e7f9a1b-2c3d-4e5f-8a6b-7c8d9e0f1a2b-000000",
"provider_error": null,
"is_test": false
}
For notification.failed_terminal, provider_message_id is null and provider_error holds the same object as the row's providerError on GET /sends/{sendId}, for example { "name": "MessageRejected", "message": "Email address is not verified." }. template_id and external_user_id are null when not applicable.
send.completed:
{
"event": "send.completed",
"occurred_at": "2026-09-14T09:31:40.001Z",
"send_id": "6a7b8c9d-0e1f-4a2b-9c3d-4e5f6a7b8c9d",
"channel_config_id": "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
"channel_config_ids": ["9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b"],
"template_id": "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
"recipient_count": 500,
"totals": { "total": 500, "delivered": 487, "failed_terminal": 9, "suppressed": 4 }
}
channel_config_idslists the distinct channel configs across the send's rows (empty for feed-only sends).channel_config_idandtemplate_idare set for single-channel sends (template_idisnullfor inline content). For multi-channel and feed-only sends both arenull; usechannel_config_idsandGET /sends/{sendId}.recipient_countis the number of recipients for single-channel sends and1for multi-channel and feed-only sends.
TypeScript types:
type RayNotificationEvent = {
event: "notification.delivered" | "notification.failed_terminal";
occurred_at: string;
send_id: string;
notification_log_id: string;
channel_config_id: string;
template_id: string | null;
external_user_id: string | null;
provider_message_id: string | null;
provider_error: unknown;
is_test: boolean;
};
type RaySendCompletedEvent = {
event: "send.completed";
occurred_at: string;
send_id: string;
channel_config_id: string | null;
channel_config_ids: string[];
template_id: string | null;
recipient_count: number;
totals: { total: number; delivered: number; failed_terminal: number; suppressed: number };
};
type RayWebhookEvent = RayNotificationEvent | RaySendCompletedEvent;
Delivery behavior
- Each request is a
POSTwith headersContent-Type: application/json,User-Agent: Ray-Webhooks/1.0andX-Ray-Signature. - Respond with any
2xxwithin 10 seconds. Anything else is a failure: non-2xx, timeout, network error, or a3xx(redirects are not followed). - Failed deliveries are retried up to 10 times with exponential backoff starting at 30 seconds.
- After 10 consecutive failures the webhook is disabled (
enabled: false, reason inlastError) and further events for it are dropped. A successful delivery resets the counter. - To re-enable after fixing your endpoint, use the dashboard (Webhooks), which also resets
consecutiveFailuresto 0.PATCH /tenant-webhooks/{id}with{ "enabled": true }re-enables too, but leavesconsecutiveFailuresas is, so one more failure before a success disables the webhook again. - Delivery is at-least-once and unordered. Deduplicate on
notification_log_idfornotification.*events andsend_idforsend.completed. - Do slow work asynchronously: acknowledge first, process from a queue.
Manage subscriptions
URL rules: https only, no user:password@, and not a private, loopback, link-local or internal address (IP literals in private ranges, localhost, and hostnames ending in .local, .internal, .lan, .localhost or .cluster.local are rejected). The hostname is resolved again before every delivery, and a URL that now resolves to a private address counts as a failed delivery.
Create
POST /tenant-webhooks (write scope)
| Field | Type | Notes |
|---|---|---|
name |
string, 1 to 100 | Label. |
url |
string | Endpoint URL, rules above. |
events |
array | At least one of notification.delivered, notification.failed_terminal, send.completed. |
curl -X POST https://ray-api.gege.mn/tenant-webhooks \
-H "Authorization: Bearer $RAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "production",
"url": "https://api.example.com/webhooks/ray",
"events": ["notification.failed_terminal", "send.completed"]
}'
201 Created. The secret is returned only here and when rotated. Store it now.
{
"id": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b",
"name": "production",
"url": "https://api.example.com/webhooks/ray",
"events": ["notification.failed_terminal", "send.completed"],
"enabled": true,
"consecutiveFailures": 0,
"lastDeliveryAt": null,
"lastFailureAt": null,
"lastError": null,
"archivedAt": null,
"createdAt": "2026-09-14T09:00:00.000Z",
"updatedAt": "2026-09-14T09:00:00.000Z",
"secret": "q3Xk9vB2mN7pL4sT8wY1zA6cE0gH5jK2rU9oI3dF7bM"
}
List and read
GET /tenant-webhooksreturns{ "webhooks": [...] }. Add?includeArchived=trueto include deleted ones.GET /tenant-webhooks/{id}returns one webhook. UseconsecutiveFailures,lastDeliveryAt,lastFailureAtandlastErrorto monitor health.
Update, rotate, enable or disable
PATCH /tenant-webhooks/{id} (write scope) with at least one of name, url, events, enabled, rotateSecret.
curl -X PATCH https://ray-api.gege.mn/tenant-webhooks/e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b \
-H "Authorization: Bearer $RAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "rotateSecret": true }'
With rotateSecret: true the response includes the new secret. The old secret stops working immediately, so deploy the new one to your receiver right away (or accept either secret during the switch).
Delete
DELETE /tenant-webhooks/{id} archives the webhook and returns { "id": "...", "archived": true }. Archived webhooks receive nothing and cannot be updated.
Verify signatures
The header looks like this:
X-Ray-Signature: t=1789378201,v1=5f2b7c1e9a0d4b3c8e6f1a2d7b9c0e4f3a5d8b1c6e2f9a7d0b4c3e8f1a6d2b9c
tis the Unix timestamp (seconds) when Ray signed the request.v1is the lowercase hex HMAC-SHA256 of the string<t>.<raw request body>, keyed with your webhook secret.- Use the secret string exactly as returned (as UTF-8 bytes). Do not base64-decode it.
To verify: parse t and v1, reject timestamps more than 5 minutes from now, compute the HMAC over the exact raw body bytes you received, and compare in constant time. Parsing and re-serializing the JSON before verifying changes the bytes and breaks the signature.
With the TypeScript SDK
The TypeScript SDK exports verifyWebhookSignature, which implements the checks above with Web Crypto, so it runs in Node.js, Bun, Deno and edge runtimes. It takes the raw body, the request headers and the secret (or an array of secrets while you rotate):
// app/api/webhooks/ray/route.ts
import { verifyWebhookSignature } from "@gege-mn/ray";
export async function POST(request: Request) {
const payload = await request.text();
const valid = await verifyWebhookSignature({
payload,
headers: request.headers,
secret: process.env.RAY_WEBHOOK_SECRET!,
});
if (!valid) return new Response("invalid signature", { status: 400 });
const event = JSON.parse(payload) as RayWebhookEvent;
// handle the event idempotently, then acknowledge
return new Response(null, { status: 204 });
}
parseWebhookEvent does the same and returns the typed event; an Express version is in TypeScript SDK. Without the SDK, use one of the functions below.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyRaySignature(
rawBody: string | Buffer,
header: string | null | undefined,
secret: string,
toleranceSeconds = 300,
): boolean {
if (!header) return false;
let t: number | undefined;
let v1: string | undefined;
for (const part of header.split(",")) {
const [key, value] = part.trim().split("=", 2);
if (key === "t") t = Number(value);
if (key === "v1") v1 = value;
}
if (!t || !Number.isFinite(t) || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody)
.digest();
const received = Buffer.from(v1, "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}
Next.js App Router route handler:
// app/api/webhooks/ray/route.ts
import { verifyRaySignature } from "@/lib/ray-webhooks";
export async function POST(request: Request) {
const rawBody = await request.text();
const ok = verifyRaySignature(rawBody, request.headers.get("x-ray-signature"), process.env.RAY_WEBHOOK_SECRET!);
if (!ok) return new Response("invalid signature", { status: 400 });
const event = JSON.parse(rawBody) as RayWebhookEvent;
switch (event.event) {
case "notification.failed_terminal":
await markDeliveryFailed(event.notification_log_id, event.provider_error); // your code, idempotent
break;
case "send.completed":
await recordSendTotals(event.send_id, event.totals); // your code, idempotent
break;
}
return new Response(null, { status: 204 });
}
Express (keep the body raw for this route):
import express from "express";
import { verifyRaySignature } from "./ray-webhooks";
const app = express();
app.post("/webhooks/ray", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body as Buffer;
if (!verifyRaySignature(rawBody, req.header("x-ray-signature"), process.env.RAY_WEBHOOK_SECRET!)) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(rawBody.toString("utf8"));
res.status(204).end();
void handleRayEvent(event); // process after acknowledging
});
Web Crypto (Cloudflare Workers, Deno, Bun, edge runtimes)
export async function verifyRaySignatureWeb(
rawBody: string,
header: string | null,
secret: string,
toleranceSeconds = 300,
): Promise<boolean> {
if (!header) return false;
const fields = new Map(
header.split(",").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i).trim(), p.slice(i + 1).trim()] as const;
}),
);
const t = Number(fields.get("t"));
const v1 = fields.get("v1");
if (!Number.isFinite(t) || t <= 0 || !v1 || !/^[0-9a-f]{64}$/i.test(v1)) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"],
);
const signature = new Uint8Array(v1.match(/../g)!.map((h) => parseInt(h, 16)));
// subtle.verify compares in constant time.
return crypto.subtle.verify("HMAC", key, signature, enc.encode(`${t}.${rawBody}`));
}
Errors
| Status | error |
message |
|---|---|---|
400 |
validation_error |
url: URL must use https, url: URL targets a private/loopback/link-local address, url: URL targets an internal hostname, body: patch body must include at least one field, write scope required |
403 |
forbidden |
delivery webhooks require the Pro plan or higher |
404 |
not_found |
webhook not found, webhook not found or already archived |