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.
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
httpson a public address (private, loopback, link-local and similar addresses are refused, including hostnames that resolve to them), - accepts
POSTwith a JSON body, - answers
2xxwithin 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-Timestampis an ISO 8601 timestamp, for example2026-09-14T09:30:01.234Z.X-Ray-Signatureissha256=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
3xxresponse 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 sameidand a newsentAtand signature.- Any other
4xxfails 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.