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

Channels

HTTPS webhook channel

Deliver Ray notifications to your own HTTPS endpoint as a signed JSON envelope. Config, payload format, signature verification, retries and errors.

View as Markdownllms.txtAPI reference

The generic_webhook channel delivers notifications to any HTTPS endpoint you run, such as an internal service, a Zapier or Make catch hook, or a chat bot backend. Ray POSTs a small JSON envelope, optionally HMAC-signed, and retries on failure. API calls go to https://ray-api.gege.mn with Authorization: Bearer $RAY_API_KEY.

This is a delivery channel: you send notifications through it with POST /send. It is different from delivery webhooks, which tell you about the status of sends and use a different signature format.

Channel type generic_webhook
Template kind webhook_json
Recipient {}
Pacing None
Suppression None

What you need

An endpoint that:

  • is reachable over https on a public address (private, loopback, link-local and similar addresses are refused, including hostnames that resolve to them),
  • accepts POST with a JSON body,
  • answers 2xx within 10 seconds, without redirecting.

Optionally generate a signing secret of 16 to 256 characters, for example with openssl rand -hex 32.

Channel config

Create the config in the dashboard: Channels, New channel, Webhook (HTTP). The secret and header values are stored encrypted.

Field Required Notes
Name yes Internal label, for example zapier-orders.
Destination URL (url) yes https URL on a public address.
Signing secret (signingSecret) no 16 to 256 characters. When set, every request is signed. When editing, leave blank to keep it.
Extra headers (headers) no JSON object of static headers added to every request, for example { "Authorization": "Bearer internal-token" }. Names: letters, digits and -. Values: max 1024 characters, no line breaks. Content-Type, X-Ray-Signature and X-Ray-Timestamp can't be overridden.

Find the config's id with GET /channels; its kind is generic_webhook.

Template content

Field Type Notes
title string, 1 to 200
body string, 1 to 4000
data object of string to string Optional. Values may contain {{variables}}.

Params are inserted without escaping; the values are JSON-encoded in the envelope, so they can't break its structure.

Recipient

Always an empty object; the destination is the configured URL.

{}

Request Ray sends

POST /hooks/ray HTTP/1.1
Host: api.example.com
Content-Type: application/json
X-Ray-Timestamp: 2026-09-14T09:30:01.234Z
X-Ray-Signature: sha256=3f1c9a0b7e2d4c6f8a1b3d5e7f9a0c2e4b6d8f1a3c5e7b9d0f2a4c6e8b1d3f5a
Authorization: Bearer internal-token

{"id":"c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f","title":"New order A-1042","body":"Ada ordered 3 items for $59.00","data":{"orderId":"A-1042","total":"59.00"},"sentAt":"2026-09-14T09:30:01.234Z"}
Envelope field Notes
id The delivery row id. The same on every retry of this delivery, so use it to deduplicate. Matches rows[].id on GET /sends/{sendId} and notification_log_id in delivery webhooks.
title Rendered title.
body Rendered body.
data Rendered data, present only if the content has data.
sentAt ISO 8601 time of this attempt.

X-Ray-Signature and X-Ray-Timestamp are sent only when a signing secret is configured. Extra headers from the config are included on every request.

Send a notification

curl -X POST https://ray-api.gege.mn/send \
  -H "Authorization: Bearer $RAY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-A-1042-webhook" \
  -d '{
    "channelConfigId": "4f5a6b7c-8d9e-4f0a-9b1c-2d3e4f5a6b7c",
    "content": {
      "title": "New order {{orderId}}",
      "body": "{{customer}} ordered {{items}} items for {{total}}",
      "data": { "orderId": "{{orderId}}", "total": "{{amount}}" }
    },
    "params": { "orderId": "A-1042", "customer": "Ada", "items": 3, "total": "$59.00", "amount": "59.00" },
    "recipient": {}
  }'
const res = await fetch("https://ray-api.gege.mn/send", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.RAY_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "order-A-1042-webhook",
  },
  body: JSON.stringify({
    channelConfigId: "4f5a6b7c-8d9e-4f0a-9b1c-2d3e4f5a6b7c",
    templateId: "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a", // a webhook_json template
    params: { orderId: "A-1042", customer: "Ada", items: 3, total: "$59.00", amount: "59.00" },
    recipient: {},
  }),
});
if (res.status !== 202) throw new Error(`Ray ${res.status}: ${await res.text()}`);

data values are always strings. Numbers in params are converted to text when substituted.

The delivery row's providerMessageId is webhook-<id>.

Verify the signature

With a signing secret configured:

  • X-Ray-Timestamp is an ISO 8601 timestamp, for example 2026-09-14T09:30:01.234Z.
  • X-Ray-Signature is sha256= followed by the lowercase hex HMAC-SHA256 of the string <X-Ray-Timestamp>.<raw body>, keyed with the secret (as UTF-8).

Note the differences from delivery webhooks: the timestamp is an ISO string in its own header, not Unix seconds inside the signature header.

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

export function verifyRayChannelWebhook(
  rawBody: string,
  signatureHeader: string | null,
  timestampHeader: string | null,
  secret: string,
  toleranceMs = 5 * 60 * 1000,
): boolean {
  if (!signatureHeader?.startsWith("sha256=") || !timestampHeader) return false;

  const sentAt = Date.parse(timestampHeader);
  if (Number.isNaN(sentAt) || Math.abs(Date.now() - sentAt) > toleranceMs) return false;

  const expected = createHmac("sha256", secret).update(`${timestampHeader}.${rawBody}`).digest();
  const received = Buffer.from(signatureHeader.slice("sha256=".length), "hex");
  return received.length === expected.length && timingSafeEqual(received, expected);
}

// Next.js App Router route handler
export async function POST(request: Request) {
  const rawBody = await request.text();
  const ok = verifyRayChannelWebhook(
    rawBody,
    request.headers.get("x-ray-signature"),
    request.headers.get("x-ray-timestamp"),
    process.env.RAY_CHANNEL_WEBHOOK_SECRET!,
  );
  if (!ok) return new Response("invalid signature", { status: 401 });

  const envelope = JSON.parse(rawBody) as {
    id: string;
    title: string;
    body: string;
    data?: Record<string, string>;
    sentAt: string;
  };
  // Deduplicate on envelope.id, then handle the notification.
  return new Response(null, { status: 204 });
}

Always verify against the raw body. The 5 minute tolerance is a suggestion for replay protection; sentAt in the body equals X-Ray-Timestamp.

Retries and timeouts

  • Requests time out after 10 seconds.
  • Redirects are not followed; a 3xx response fails the delivery permanently.
  • 408, 429, 5xx, network errors and timeouts are retried with exponential backoff (up to 5 retries starting at 30 seconds). Each retry is a new request with the same id and a new sentAt and signature.
  • Any other 4xx fails the delivery permanently.

Respond quickly and process asynchronously. Because a timed-out request may still have been processed by your endpoint, make handling idempotent on id.

Errors

Errors appear on the delivery row as providerError: { "name": "GenericWebhookError", "message": "..." }. For HTTP failures the message includes the status and up to 500 characters of your endpoint's response body, which is useful for debugging.

Result Cases
failed_terminal (not retried) HTTP 4xx except 408 and 429; any 3xx redirect; https is required for webhook URLs; refusing to POST to private address '...'; refusing host '...' that resolves to private address '...'; DNS resolution failed for '...'; an invalid URL
failed_retryable (retried) HTTP 408, 429 and 5xx, network errors and timeouts

Request errors from POST /send are listed in Errors.

PreviousSMS with sendsms.mnNextRate limits and quotas

On this page

  • What you need
  • Channel config
  • Template content
  • Recipient
  • Request Ray sends
  • Send a notification
  • Verify the signature
  • Retries and timeouts
  • 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