{"id":"db3bf537-3ef8-4969-8f8a-950b119e31b0","slug":"ray-node","kind":"mcp","name":"ray-node","summary":"Official TypeScript SDK for the Ray notification API (ray.gege.mn): email, push, Slack, Discord, Telegram and webhooks","owner":"gege-mn","repoUrl":"https://github.com/gege-mn/ray-node","license":"MIT","licenseTier":"permissive","trust":"community","stars":0,"match":null,"fewStars":true,"format":"mcp","createdAt":"2026-09-14T17:01:24.000Z","addedAt":"2026-10-21T09:42:08.612Z","logoUrl":"https://avatars.githubusercontent.com/u/301638712?v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["communication","productivity","developer-tools"],"tags":["ai-agents","notifications","push-notifications","ray","transactional-email","typescript"],"runtime":"binary","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-23T04:00:04.552Z","sample":false,"url":"https://conduits.dev/tools/ray-node","language":"TypeScript","transports":["stdio"],"install":{},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# @gege-mn/ray\n\nThe official TypeScript SDK for [Ray](https://ray.gege.mn), the notification delivery API.\n\n[![npm](https://img.shields.io/npm/v/@gege-mn/ray)](https://www.npmjs.com/package/@gege-mn/ray)\n[![CI](https://github.com/gege-mn/ray-node/actions/workflows/ci.yml/badge.svg)](https://github.com/gege-mn/ray-node/actions/workflows/ci.yml)\n\nRay is a multi-tenant notification delivery API (not the Ray distributed computing framework). You configure channels once (Amazon SES or SMTP email, Firebase push, Slack, Discord, Telegram, SMS through Twilio or sendsms.mn, or your own HTTPS webhook), keep message templates in Ray, and send with one `POST /send` call: to one recipient, fanned out to up to 1000 recipients, or to one person over several channels at once. Ray queues and paces delivery per provider, retries transient failures, tracks per-recipient status, keeps an in-app notification feed per end user, and calls your webhooks when deliveries finish.\n\n- Zero runtime dependencies, built on `fetch` and Web Crypto\n- ESM and CommonJS, full TypeScript types\n- Runs on Node.js 18+, Bun, Deno, Cloudflare Workers and Vercel Edge\n- Safe retries: `send()` always carries an `Idempotency-Key`\n- Webhook signature verification that matches Ray's signer exactly\n\nDocs: https://ray.gege.mn/docs · SDK guide: https://ray.gege.mn/docs/sdk · API reference: https://ray-api.gege.mn/docs · For AI agents: https://ray.gege.mn/llms.txt\n\n## Contents\n\n- [Install](#install)\n- [Quickstart](#quickstart)\n- [Sending](#sending)\n- [Idempotency](#idempotency)\n- [Delivery status](#delivery-status)\n- [In-app feed](#in-app-feed)\n- [Templates](#templates)\n- [Channels](#channels)\n- [Click stats](#click-stats)\n- [Delivery webhooks](#delivery-webhooks)\n- [Verifying webhooks](#verifying-webhooks)\n- [Errors](#errors)\n- [Retries, timeouts and configuration](#retries-timeouts-and-configuration)\n- [Account and usage](#account-and-usage)\n- [TypeScript](#typescript)\n- [For AI agents](#for-ai-agents)\n\n## Install\n\n```bash\nnpm install @gege-mn/ray\n# or\npnpm add @gege-mn/ray\n# or\nyarn add @gege-mn/ray\n# or\nbun add @gege-mn/ray\n```\n\nDeno: `import { Ray } from 'npm:@gege-mn/ray';`\n\n## Quickstart\n\n1. Create an API key in the Ray dashboard (https://ray.gege.mn) and add a channel (for example Amazon SES).\n2. Set `RAY_API_KEY=ck_live_...` in your server environment.\n3. Send:\n\n```ts\nimport { Ray } from '@gege-mn/ray';\n\nconst ray = new Ray(); // reads RAY_API_KEY; or new Ray('ck_live_...')\n\nconst { channels } = await ray.channels.list();\nconst email = channels.find((c) => c.kind === 'ses_email');\n\nconst { sendId } = await ray.send({\n  channelConfigId: email!.id,\n  content: {\n    subject: 'Welcome, {{name}}',\n    bodyHtml: '<p>Hi {{name}}, thanks for signing up.</p>',\n    bodyText: 'Hi {{name}}, thanks for signing up.',\n  },\n  params: { name: 'Ada' },\n  recipient: { email: 'ada@example.com' },\n});\n\nconst status = await ray.sends.get(sendId);\nconsole.log(status.aggregate); // { total: 1, pending: 1 } → later { total: 1, delivered: 1 }\n```\n\n`send()` resolves as soon as Ray accepts the request (HTTP `202`). Delivery is asynchronous; read it with `ray.sends.get()` or subscribe to [delivery webhooks](#delivery-webhooks).\n\nKeep the API key on the server. Never ship it to browsers or mobile apps.\n\n## Sending\n\n`ray.send(body, options?)` calls `POST /send` and returns `{ sendId }`. A request uses exactly one of four modes, and every channel delivery uses exactly one content source: `templateId` (a published template) or inline `content`. The `SendBody` type rejects invalid combinations at compile time.\n\n### Single recipient, with a template\n\n```ts\nconst { sendId } = await ray.send(\n  {\n    channelConfigId: '9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b',\n    templateId: '1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a',\n    params: { name: 'Ada', code: '482913' },\n    recipient: { email: 'ada@example.com', name: 'Ada Lovelace' },\n    externalUserId: 'user_123', // your id for the end user\n    showInFeed: true, // also add an entry to user_123's in-app feed\n  },\n  { idempotencyKey: 'signup-code-user_123-482913' },\n);\n```\n\n### Single recipient, with inline content\n\nThe `content` shape depends on the channel's template kind (see [Channels](#channels)). Here, a push notification:\n\n```ts\nawait ray.send({\n  channelConfigId: '3a4b5c6d-7e8f-4a9b-8c0d-1e2f3a4b5c6d', // fcm_push\n  content: { title: 'Order shipped', body: 'Order {{orderId}} is on its way.' },\n  logTitle: 'Order shipped', // feed title, inline content only\n  logDescription: 'Order {{orderId}} left the warehouse.',\n  params: { orderId: 'A-1042' },\n  recipient: { deviceToken: 'fcm-device-token-from-your-app' },\n  externalUserId: 'user_123',\n  showInFeed: true,\n});\n```\n\nWith inline content every top-level `{{variable}}` in `content`, `logTitle` and `logDescription` needs a value in `params`.\n\n### Fan-out (1 to 1000 recipients)\n\n`targets[]` sends the same rendered message to many recipients on one channel in one request (one `sendId`, one rate-limit token).\n\n```ts\nconst users = [\n  { id: 'user_1', email: 'ada@example.com' },\n  { id: 'user_2', email: 'grace@example.com' },\n];\n\nawait ray.send({\n  channelConfigId: '9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b',\n  templateId: '1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a',\n  params: { releaseName: 'September update' }, // one params set for everyone\n  targets: users.map((u) => ({ recipient: { email: u.email }, externalUserId: u.id })),\n  showInFeed: true, // one feed entry per target with an externalUserId\n  priority: 'low', // bulk: never delays transactional sends\n});\n```\n\nOne invalid target rejects the whole request with `400`. For per-recipient values (a name, a code) make one send per recipient. For more than 1000 recipients, split into several sends.\n\n### Multi-channel (one person, 1 to 10 channels)\n\n```ts\nawait ray.send({\n  externalUserId: 'user_42',\n  params: { feature: 'dark mode' },\n  feed: { title: 'New feature is live', description: 'Try dark mode in settings.' },\n  deliveries: [\n    {\n      channelConfigId: '3a4b5c6d-7e8f-4a9b-8c0d-1e2f3a4b5c6d', // push\n      content: { title: 'New: {{feature}}', body: 'Open settings to try it.' },\n      recipient: { deviceToken: 'fcm-device-token-from-your-app' },\n    },\n    {\n      channelConfigId: '9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b', // email\n      templateId: '1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a',\n      recipient: { email: 'ada@example.com' },\n    },\n    {\n      channelConfigId: '7c8d9e0f-1a2b-4c3d-9e4f-5a6b7c8d9e0f', // slack\n      content: { text: 'Shipped *{{feature}}* to user_42' },\n      params: { feature: 'dark mode (beta)' }, // replaces top-level params for this delivery\n      recipient: {},\n    },\n  ],\n});\n```\n\n`feed` with `deliveries` creates exactly one feed entry (requires top-level `externalUserId`). Each channel is delivered and retried independently.\n\n### Feed-only (in-app notification, no channel)\n\n```ts\nawait ray.send({\n  externalUserId: 'user_42',\n  feed: { title: 'Your export is ready', description: 'Download it from the Reports page.' },\n});\n```\n\n### Scheduling\n\nPass `notBefore` (ISO 8601, offsets allowed) to deliver later. Feed entries become visible at that time too.\n\n```ts\nawait ray.send({\n  channelConfigId: '9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b',\n  templateId: '1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a',\n  recipient: { email: 'ada@example.com' },\n  notBefore: '2026-09-15T09:00:00+08:00',\n});\n```\n\n### Email attachments, cc and bcc\n\nAttachments are base64 inside the email recipient (up to 20 files, about 10 MiB each, about 25 MiB total; `POST /send` accepts bodies up to 45 MiB).\n\n```ts\nimport { readFile } from 'node:fs/promises';\n\nawait ray.send({\n  channelConfigId: '9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b',\n  content: {\n    subject: 'Invoice {{number}}',\n    bodyHtml: '<p>Your invoice is attached.</p><img src=\"cid:logo\">',\n    bodyText: 'Your invoice is attached.',\n  },\n  params: { number: 'INV-1042' },\n  recipient: {\n    email: 'ada@example.com',\n    cc: [{ email: 'billing@example.com', name: 'Billing' }],\n    bcc: [{ email: 'archive@example.com' }],\n    attachments: [\n      {\n        filename: 'INV-1042.pdf',\n        content: (await readFile('INV-1042.pdf')).toString('base64'),\n        contentType: 'application/pdf',\n      },\n      {\n        filename: 'logo.png',\n        content: (await readFile('logo.png')).toString('base64'),\n        contentType: 'image/png',\n        disposition: 'inline',\n        contentId: 'logo',\n      },\n    ],\n  },\n  trackClicks: true, // email only: rewrite links for click stats\n  campaignId: 'invoices-2026-09',\n});\n```\n\n### Email sender, reply-to and headers\n\nThe channel config's from address and name are the default. Override them per send with `from` (each field you leave out keeps the config's value), and set `replyTo` and custom `headers` on the same recipient:\n\n```ts\nawait ray.send({\n  channelConfigId: '9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b',\n  templateId: '1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a',\n  params: { number: 'INV-1042' },\n  recipient: {\n    email: 'ada@example.com',\n    from: { email: 'billing@example.com', name: 'Example Billing' },\n    replyTo: [{ email: 'support@example.com', name: 'Support' }],\n    headers: { 'List-Unsubscribe': '<https://example.com/unsubscribe/ada>' },\n  },\n});\n```\n\nThe from address must be on the same domain as the channel config's from address (another domain, including a subdomain, is a `400`), and one your provider lets you send as (a verified SES identity, or an address your SMTP server accepts). Header names and values are printable ASCII, up to 15 headers. Headers Ray sets itself (`From`, `Reply-To`, `To`, `Cc`, `Bcc`, `Subject`, `Message-ID`, …) and provider controls (`X-SES-*`, `X-SMTPAPI`, `X-MC-*`, `X-MSYS-*`, `X-PM-*`, `X-Mailgun-*`, `X-MJ-*`, `X-Mailjet-*`, `X-Mailin-*`) are rejected with a `400`.\n\n### Other send fields\n\n| Field | Notes |\n| --- | --- |\n| `priority` | `'high'` (default) or `'low'` for bulk/marketing. |\n| `trackClicks` | Email only. Read results with `ray.clicks.get()`. |\n| `campaignId` | Groups click stats across sends. |\n| `externalUserId` | Your end-user id (1 to 256 chars); needed for feed entries. |\n| `showInFeed` / `feed` | Create in-app feed entries. See https://ray.gege.mn/docs/sending#feed-entries. |\n\n## Idempotency\n\n`send()` always sends an `Idempotency-Key` header:\n\n- If you pass `{ idempotencyKey }`, that key is used.\n- Otherwise the SDK generates a random UUID for the call and reuses it on every retry of that call, so the SDK's own retries can never deliver twice.\n\nA generated key only protects retries inside one `send()` call. If your own code retries (a queue job that runs again, a process that restarts), pass a key derived from the business event so Ray deduplicates across attempts for 24 hours:\n\n```ts\nawait ray.send(body, { idempotencyKey: `order-confirmation-${order.id}` });\n```\n\nSame key and same body within 24 hours returns the original `{ sendId }` without sending again. Same key with a different body throws `RayError` `409 conflict`. Only `POST /send` supports idempotency keys. Details: https://ray.gege.mn/docs/idempotency\n\n## Delivery status\n\n```ts\nconst detail = await ray.sends.get(sendId, { limit: 50 });\ndetail.aggregate; // { total: 3, delivered: 2, suppressed: 1 } (whole send)\ndetail.rows; // this page of delivery rows, oldest first\ndetail.nextCursor; // pass as { cursor } for the next page, null on the last page\n\n// Every row of a large fan-out:\nfor await (const row of ray.sends.listAllRows(sendId)) {\n  if (row.status === 'failed_terminal') console.warn(row.recipient, row.providerError);\n}\n```\n\nStatuses: `pending`, `claimed`, `failed_retryable` (in progress) and `delivered`, `failed_terminal`, `suppressed` (final). Prefer [delivery webhooks](#delivery-webhooks) over polling.\n\n## In-app feed\n\nFeed entries are created by `send()` with `showInFeed` or `feed`. Read them per end user (newest first) from your backend:\n\n```ts\nconst page = await ray.notifications.list({ externalUserId: 'user_123', limit: 20 });\npage.notifications; // [{ id, sendId, logTitle, logDescription, createdAt, ... }]\npage.nextCursor;\n\n// Filters: templateId, channelConfigId, after, before (ISO 8601 UTC with Z)\nfor await (const entry of ray.notifications.listAll({\n  externalUserId: 'user_123',\n  after: '2026-09-01T00:00:00Z',\n})) {\n  console.log(entry.logTitle);\n}\n```\n\n`logTitle` and `logDescription` are not HTML-escaped by Ray. Render them as text.\n\n## Templates\n\n```ts\n// Create (and publish) a template\nconst { id, requiredParams } = await ray.templates.create({\n  name: 'welcome-email',\n  folder: 'onboarding',\n  channelKind: 'email_html',\n  content: {\n    subject: 'Welcome, {{name}}',\n    bodyHtml: '<p>Hi {{name}}</p>',\n    bodyText: 'Hi {{name}}',\n  },\n  logTitle: 'Welcome email sent',\n  logDescription: 'Welcomed {{name}}',\n  publish: true,\n});\n\nconst { templates } = await ray.templates.list({ folder: 'onboarding', includeArchived: false });\nconst template = await ray.templates.get(id); // includes `published` and `draft` versions\n\n// Update the draft (full body; channelKind can't change), then publish it\nawait ray.templates.update(id, {\n  name: 'welcome-email',\n  channelKind: 'email_html',\n  content: { subject: 'Welcome aboard, {{name}}', bodyHtml: '<p>Hi {{name}}</p>', bodyText: 'Hi {{name}}' },\n  logTitle: 'Welcome email sent',\n  logDescription: 'Welcomed {{name}}',\n});\nawait ray.templates.publish(id);\n\n// Send the published version once to yourself (no feed entry, no webhooks)\nawait ray.templates.testSend(id, {\n  channelConfigId: '9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b',\n  recipient: { email: 'me@example.com' },\n  params: { name: 'Test' },\n});\n\nawait ray.templates.archive(id);\nawait ray.templates.unarchive(id);\n```\n\nTemplates use `{{variable}}` placeholders and `{{#section}}...{{/section}}` blocks. See https://ray.gege.mn/docs/templates\n\n## Channels\n\n```ts\nconst { channels } = await ray.channels.list();\n// [{ id, name, kind: 'ses_email', templateKind: 'email_html', recipientSchema: { ...JSON Schema } }]\n```\n\nChannels are configured in the dashboard. `channelConfigId` in `send()` is a channel's `id`.\n\n| Channel `kind` | Template kind | `recipient` | Inline `content` |\n| --- | --- | --- | --- |\n| `ses_email`, `smtp_email` | `email_html` | `{ email, name?, cc?, bcc?, from?, replyTo?, headers?, attachments? }` | `{ subject, bodyHtml, bodyText }` |\n| `fcm_push` | `fcm_basic` | `{ deviceToken }` or `{ topic }` | `{ title, body, imageUrl?, data? }` |\n| `slack_webhook` | `slack_text` | `{}` | `{ text }` |\n| `discord_webhook` | `discord_text` | `{}` | `{ content }` |\n| `telegram_bot` | `telegram_text` | `{ chatId }` | `{ text, disableLinkPreview? }` |\n| `twilio_sms` | `sms_text` | `{ phoneNumber }` (E.164, e.g. `'+97699112233'`) | `{ text }` |\n| `sendsms_mn` | `sms_text` | `{ phoneNumber }` (Mongolian 8 digits, e.g. `'99112233'`) | `{ text }` |\n| `generic_webhook` | `webhook_json` | `{}` | `{ title, body, data? }` |\n\nSMS (`twilio_sms`, `sendsms_mn`) is plain text of up to 1600 characters; param values are inserted verbatim. On Twilio, text longer than 1600 characters after params are filled in makes `send()` / `testSend()` throw a `400`. Long messages go out as several SMS. Twilio splits them itself. sendsms.mn accepts one SMS per request, so Ray splits the message at spaces and line breaks into parts of at most 159 characters of plain Latin (GSM-7) text, or 69 once a part has any other character, such as Cyrillic; each part is sent in order, billed as one SMS and arrives as a separate message. A split message's `providerMessageId` is the comma-separated sendsms.mn ids, and it counts once toward `ratePerMinute`. sendsms.mn failures after which an SMS may already be out (a timeout, or any failure after the first part was sent) are not retried, to avoid sending it twice.\n\n```ts\nawait ray.send({\n  channelConfigId: '8e9f0a1b-2c3d-4e5f-8a6b-7c8d9e0f1a2b', // twilio_sms\n  content: { text: 'Your Acme code is {{code}}. It expires in 10 minutes.' },\n  logTitle: 'Login code',\n  logDescription: 'Login code sent by SMS',\n  params: { code: '482913' },\n  recipient: { phoneNumber: '+97699112233' }, // sendsms_mn: '99112233'\n});\n```\n\nFailed SMS deliveries carry `providerError.name` `TwilioError` or `SendsmsMnError` on the delivery row.\n\n## Click stats\n\n```ts\nconst stats = await ray.clicks.get({ sendId }); // or { campaignId: 'invoices-2026-09' }\nstats.totalClicks;\nstats.byUrl; // [{ url, clicks }]\n```\n\n## Delivery webhooks\n\nRay POSTs to your endpoint when a delivery reaches a final state (Pro plan and above).\n\n```ts\nconst webhook = await ray.webhooks.create({\n  name: 'production',\n  url: 'https://api.example.com/webhooks/ray',\n  events: ['notification.failed_terminal', 'send.completed'],\n});\nconsole.log(webhook.secret); // shown only now: store it as RAY_WEBHOOK_SECRET\n\nconst { webhooks } = await ray.webhooks.list();\nconst one = await ray.webhooks.get(webhook.id); // consecutiveFailures, lastError, ...\n\nawait ray.webhooks.update(webhook.id, { events: ['send.completed'], enabled: true });\nconst { secret } = await ray.webhooks.update(webhook.id, { rotateSecret: true }); // old secret stops working\nawait ray.webhooks.delete(webhook.id); // archives it\n```\n\nEvents: `notification.delivered`, `notification.failed_terminal`, `send.completed`. Delivery is at-least-once and unordered: deduplicate on `notification_log_id` (notification events) or `send_id` (`send.completed`). Details: https://ray.gege.mn/docs/webhooks\n\n## Verifying webhooks\n\nRay signs every delivery webhook with the `X-Ray-Signature` header:\n\n```http\nX-Ray-Signature: t=1789378201,v1=5f2b7c1e9a0d4b3c8e6f1a2d7b9c0e4f3a5d8b1c6e2f9a7d0b4c3e8f1a6d2b9c\n```\n\n`v1` is the hex HMAC-SHA256 of `\"<t>.<raw request body>\"` keyed with the webhook secret (UTF-8, not base64-decoded). The SDK verifies it with Web Crypto, so the same code works on every runtime.\n\n```ts\nverifyWebhookSignature(options: {\n  payload: string | Uint8Array | ArrayBuffer; // the RAW body, exactly as received\n  headers: Headers | Record<string, string | string[] | undefined>; // must contain x-ray-signature\n  secret: string | string[]; // several secrets are accepted during a rotation\n  toleranceSeconds?: number; // default 300; 0 disables the timestamp check\n}): Promise<boolean>\n\nparseWebhookEvent(sameOptions): Promise<WebhookEvent> // throws WebhookVerificationError\n```\n\n`parseWebhookEvent` verifies and returns the typed event, or throws `WebhookVerificationError` with `reason` = `missing_signature` | `malformed_signature` | `timestamp_out_of_tolerance` | `signature_mismatch` | `invalid_payload`.\n\nAlways verify the raw body. Parsing and re-serializing JSON changes the bytes and breaks the signature.\n\n### Next.js (App Router route handler)\n\n```ts\n// app/api/webhooks/ray/route.ts\nimport { parseWebhookEvent, WebhookVerificationError } from '@gege-mn/ray';\n\nexport async function POST(request: Request) {\n  let event;\n  try {\n    event = await parseWebhookEvent({\n      payload: await request.text(),\n      headers: request.headers,\n      secret: process.env.RAY_WEBHOOK_SECRET!,\n    });\n  } catch (err) {\n    if (err instanceof WebhookVerificationError) {\n      return new Response(`invalid signature: ${err.reason}`, { status: 400 });\n    }\n    throw err;\n  }\n\n  switch (event.event) {\n    case 'notification.failed_terminal':\n      await markDeliveryFailed(event.notification_log_id, event.provider_error); // your code, idempotent\n      break;\n    case 'send.completed':\n      await recordSendTotals(event.send_id, event.totals); // your code, idempotent\n      break;\n  }\n  return new Response(null, { status: 204 });\n}\n```\n\n### Express\n\n```ts\nimport express from 'express';\nimport { verifyWebhookSignature, type WebhookEvent } from '@gege-mn/ray';\n\nconst app = express();\n\n// Keep the body raw for this route (register before any app-wide express.json()).\napp.post('/webhooks/ray', express.raw({ type: 'application/json' }), async (req, res) => {\n  const ok = await verifyWebhookSignature({\n    payload: req.body, // Buffer\n    headers: req.headers,\n    secret: process.env.RAY_WEBHOOK_SECRET!,\n  });\n  if (!ok) return res.status(400).send('invalid signature');\n\n  const event = JSON.parse(req.body.toString('utf8')) as WebhookEvent;\n  res.status(204).end(); // acknowledge within 10 seconds\n  void handleRayEvent(event); // then process\n});\n```\n\n### Cloudflare Workers\n\n```ts\nimport { parseWebhookEvent } from '@gege-mn/ray';\n\nexport default {\n  async fetch(request: Request, env: { RAY_WEBHOOK_SECRET: string }): Promise<Response> {\n    const event = await parseWebhookEvent({\n      payload: await request.text(),\n      headers: request.headers,\n      secret: env.RAY_WEBHOOK_SECRET,\n    }).catch(() => null);\n    if (!event) return new Response('invalid signature', { status: 400 });\n\n    if (event.event === 'send.completed') {\n      console.log(event.send_id, event.totals);\n    }\n    return new Response(null, { status: 204 });\n  },\n};\n```\n\n### Testing your handler\n\n`signWebhookPayload` produces a header exactly like Ray's:\n\n```ts\nimport { signWebhookPayload } from '@gege-mn/ray';\n\nconst body = JSON.stringify({ event: 'send.completed', send_id: 's_1' /* ... */ });\nconst signature = await signWebhookPayload({ payload: body, secret: 'test-secret' });\nawait fetch('http://localhost:3000/api/webhooks/ray', {\n  method: 'POST',\n  headers: { 'content-type': 'application/json', 'x-ray-signature': signature },\n  body,\n});\n```\n\n### The `generic_webhook` channel\n\nNotifications sent through an HTTPS webhook *channel* use a different signature: `X-Ray-Timestamp: <ISO 8601>` and `X-Ray-Signature: sha256=<hex HMAC-SHA256 of \"<X-Ray-Timestamp>.<raw body>\">`. Use `verifyChannelWebhookSignature` (same options, returns `Promise<boolean>`) or `parseChannelWebhook` (returns `{ id, title, body, data?, sentAt }`):\n\n```ts\nimport { parseChannelWebhook } from '@gege-mn/ray';\n\nconst envelope = await parseChannelWebhook({\n  payload: await request.text(),\n  headers: request.headers,\n  secret: process.env.RAY_CHANNEL_WEBHOOK_SECRET!,\n});\n// Deduplicate on envelope.id (the same on every retry).\n```\n\n## Errors\n\nEvery failed call throws a `RayError`:\n\n| Property | Meaning |\n| --- | --- |\n| `status` | HTTP status. `0` when no response arrived (`connection_error`, `timeout`). |\n| `code` | Stable machine-readable code from the API's `error` field. Branch on this. |\n| `message` | Human-readable detail from the API. Don't parse it. |\n| `requestId` | The `x-request-id` response header, for support requests. |\n| `retryAfter` | Seconds to wait (429). |\n| `body` | Parsed error body (a `402` carries `limit` and `used`). |\n| `headers` | Response headers. |\n\n```ts\nimport { RayError } from '@gege-mn/ray';\n\ntry {\n  await ray.send(body, { idempotencyKey: `welcome-${user.id}` });\n} catch (err) {\n  if (!(err instanceof RayError)) throw err;\n  switch (err.code) {\n    case 'validation_error': // 400: fix the request (bad recipient, missing params, write scope...)\n    case 'not_found': // 404: wrong channelConfigId/templateId, or template not published\n      console.error(err.status, err.code, err.message, err.requestId);\n      throw err;\n    case 'quota_exceeded': // 402: Free plan monthly quota reached\n      break;\n    case 'rate_limit_exceeded': // 429: already retried by the SDK; err.retryAfter seconds\n    case 'internal_server_error': // 5xx: already retried by the SDK\n    case 'connection_error':\n    case 'timeout':\n      // re-enqueue the job; reuse the same idempotencyKey\n      break;\n  }\n}\n```\n\n| Status | `code` |\n| --- | --- |\n| 400 | `validation_error` |\n| 401 | `unauthorized` |\n| 402 | `quota_exceeded` |\n| 403 | `forbidden` (plan lacks the feature, e.g. delivery webhooks) |\n| 404 | `not_found` |\n| 409 | `conflict` (idempotency key reuse, duplicate template name) |\n| 413 | `payload_too_large` |\n| 429 | `rate_limit_exceeded` |\n| 500 | `internal_server_error` |\n\nA `202` from `send()` is not a delivery. Provider failures appear later on delivery rows and in webhooks. Full list: https://ray.gege.mn/docs/errors\n\n## Retries, timeouts and configuration\n\n```ts\nconst ray = new Ray('ck_live_...', {\n  baseUrl: 'https://ray-api.gege.mn', // default (or RAY_BASE_URL)\n  timeoutMs: 30_000, // per attempt, default 30s; 0 disables\n  maxRetries: 2, // default 2\n  fetch: customFetch, // default: global fetch\n  userAgent: 'my-app/1.0', // prepended to \"ray-node/<version>\"\n});\n\n// Also accepted: new Ray({ apiKey, ...options })\n\n// Per call:\nawait ray.channels.list({ timeoutMs: 5_000, maxRetries: 0, signal: AbortSignal.timeout(10_000), headers: {} });\n```\n\nThe API key defaults to `process.env.RAY_API_KEY`. The constructor throws `RayError` (`code: 'missing_api_key'`) when neither is set.\n\nRetry rules (exponential backoff 0.5s, 1s, 2s, 4s, 8s max, with up to 25% jitter; `Retry-After` is honored when present, and a requested wait over 60s is not retried):\n\n| Call | Retried on |\n| --- | --- |\n| `GET` requests (`sends.get`, `notifications.list`, `templates.list`, `me`, ...) | Network errors, timeouts, `429`, `5xx` |\n| `send()` (always has an `Idempotency-Key`) | Network errors, timeouts, `429`, `5xx`, and `409` \"request with this Idempotency-Key is already in progress\" |\n| Every other write (`templates.create/update/publish/archive/unarchive/testSend`, `webhooks.create/update/delete`) | `429` only |\n\nWhy writes other than `send()` are conservative: Ray has no idempotency keys for them, and a network error or `5xx` doesn't tell you whether the change happened (a retried `testSend` sends twice, a retried `rotateSecret` loses the first new secret). A `429` is safe to retry because Ray's rate limiter rejects the request before it runs. Handle other failures of those calls yourself.\n\nAborting `signal` cancels the in-flight request and any pending retry, and rejects with the signal's reason.\n\n## Account and usage\n\n```ts\nconst me = await ray.me(); // { tenantId, apiKeyId, scopes: ['read', 'write'] }\nconst usage = await ray.usage();\n// { plan: 'starter', subscription: {...} | null, usage: { monthlyQuota, used, remaining } }\n```\n\n## TypeScript\n\nAll request and response types are exported:\n\n```ts\nimport type {\n  SendBody, // SingleSendBody | FanOutSendBody | MultiChannelSendBody | FeedOnlySendBody\n  SendAccepted,\n  SendDetail,\n  SendRow,\n  DeliveryStatus,\n  Recipient, // EmailRecipient | FcmRecipient | TelegramRecipient | TwilioSmsRecipient | SendsmsMnRecipient | EmptyRecipient\n  TemplateContent, // EmailHtmlContent | FcmContent | SlackContent | DiscordContent | TelegramContent | SmsTextContent | WebhookJsonContent\n  Template,\n  TemplateDetail,\n  TemplateUpsertBody,\n  FeedNotification,\n  Channel,\n  Webhook,\n  WebhookEvent, // NotificationWebhookEvent | SendCompletedWebhookEvent\n  RequestOptions,\n  paths, // raw OpenAPI-generated types\n  components,\n} from '@gege-mn/ray';\n```\n\nResponse types are generated from Ray's OpenAPI spec (`pnpm generate`); request types are hand-written to match the API's validation.\n\n## For AI agents\n\n- Docs index for LLMs: https://ray.gege.mn/llms.txt (all docs in one file: https://ray.gege.mn/llms-full.txt; every page as markdown at `https://ray.gege.mn/docs/<slug>.md`)\n- OpenAPI 3.1: https://ray-api.gege.mn/openapi.json\n- Hosted MCP server: `https://ray-api.gege.mn/mcp` (Streamable HTTP, `Authorization: Bearer <RAY_API_KEY>`), e.g. `claude mcp add --transport http ray https://ray-api.gege.mn/mcp --header \"Authorization: Bearer $RAY_API_KEY\"`\n- Local MCP bridge: `npx -y @gege-mn/ray-mcp` (https://github.com/gege-mn/ray-mcp)\n- Agent skills: `npx skills add gege-mn/ray-skills` (https://github.com/gege-mn/ray-skills)\n\nIntegration checklist: call `ray.channels.list()` to get `channelConfigId` and the recipient shape, send with `ray.send()` (pass a business-derived `idempotencyKey`), check results with `ray.sends.get()` or delivery webhooks verified by `parseWebhookEvent`, and branch on `RayError.code`.\n\n## License\n\n[MIT](./LICENSE) © gege.mn\n"}