Ray
PricingDocsSign inStart free
Browse the docs

Getting started

Ray documentationAuthenticationUsing Ray with AI coding agentsMCP serverTypeScript SDK

Guides

Sending notificationsIdempotencyTemplatesScheduling and test sendsStatus and feedsClick trackingDelivery webhooks

Channels

Email with Amazon SESEmail with SMTPPush notifications with Firebase Cloud MessagingSlack notificationsDiscord notificationsTelegram notificationsSMS with TwilioSMS with sendsms.mnHTTPS webhook channel

Reference

Rate limits and quotasErrors

Getting started

Ray documentationAuthenticationUsing Ray with AI coding agentsMCP serverTypeScript SDK

Guides

Sending notificationsIdempotencyTemplatesScheduling and test sendsStatus and feedsClick trackingDelivery webhooks

Channels

Email with Amazon SESEmail with SMTPPush notifications with Firebase Cloud MessagingSlack notificationsDiscord notificationsTelegram notificationsSMS with TwilioSMS with sendsms.mnHTTPS webhook channel

Reference

Rate limits and quotasErrors

Guides

Delivery webhooks

Receive signed Ray delivery webhooks for delivered, failed and completed sends, manage subscriptions, and verify X-Ray-Signature in TypeScript.

View as Markdownllms.txtAPI reference

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_ids lists the distinct channel configs across the send's rows (empty for feed-only sends).
  • channel_config_id and template_id are set for single-channel sends (template_id is null for inline content). For multi-channel and feed-only sends both are null; use channel_config_ids and GET /sends/{sendId}.
  • recipient_count is the number of recipients for single-channel sends and 1 for 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 POST with headers Content-Type: application/json, User-Agent: Ray-Webhooks/1.0 and X-Ray-Signature.
  • Respond with any 2xx within 10 seconds. Anything else is a failure: non-2xx, timeout, network error, or a 3xx (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 in lastError) 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 consecutiveFailures to 0. PATCH /tenant-webhooks/{id} with { "enabled": true } re-enables too, but leaves consecutiveFailures as is, so one more failure before a success disables the webhook again.
  • Delivery is at-least-once and unordered. Deduplicate on notification_log_id for notification.* events and send_id for send.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-webhooks returns { "webhooks": [...] }. Add ?includeArchived=true to include deleted ones.
  • GET /tenant-webhooks/{id} returns one webhook. Use consecutiveFailures, lastDeliveryAt, lastFailureAt and lastError to 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
  • t is the Unix timestamp (seconds) when Ray signed the request.
  • v1 is 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
PreviousClick trackingNextEmail with Amazon SES

On this page

  • Events
  • Which events fire
  • Payloads
  • Delivery behavior
  • Manage subscriptions
  • Create
  • List and read
  • Update, rotate, enable or disable
  • Delete
  • Verify signatures
  • With the TypeScript SDK
  • Node.js
  • Web Crypto (Cloudflare Workers, Deno, Bun, edge runtimes)
  • Errors
Ray

Notification delivery for teams that already have providers.

Product

PricingDocsAPI referenceMCP serverTypeScript SDKAgent skillsllms.txt

Legal

TermsPrivacyRefunds

Contact

[email protected]

© 2026 L’Atelier Gege LLC