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

Click tracking

Count link clicks in Ray emails with trackClicks, group them across sends with campaignId, and read click stats with GET /clicks.

View as Markdownllms.txtAPI reference

Set trackClicks: true on an email POST /send and Ray rewrites the links in the HTML body so each click is counted before the recipient is redirected to the original URL. Read the counts with GET /clicks. Requests go to https://ray-api.gege.mn with Authorization: Bearer $RAY_API_KEY.

Click tracking is available on the Starter plan and above (see https://ray.gege.mn/pricing). On the Free plan trackClicks is accepted but ignored: links are sent unchanged.

Enable it on a send

curl -X POST https://ray-api.gege.mn/send \
  -H "Authorization: Bearer $RAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channelConfigId": "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
    "templateId": "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
    "params": { "name": "Ada" },
    "recipient": { "email": "[email protected]" },
    "trackClicks": true,
    "campaignId": "spring-sale-2026"
  }'
await fetch("https://ray-api.gege.mn/send", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.RAY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    channelConfigId: "9f1c2a7e-3b4d-4e5f-8a6b-1c2d3e4f5a6b",
    templateId: "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a",
    params: { name: "Ada" },
    recipient: { email: "[email protected]" },
    trackClicks: true,
    campaignId: "spring-sale-2026",
  }),
});

What gets rewritten

  • Only email channels (ses_email, smtp_email). On other channels trackClicks has no effect; in a multi-channel deliveries[] send only the email deliveries are rewritten.
  • Only the HTML body (bodyHtml). The plain-text part is left alone.
  • Only anchors with an absolute http:// or https:// href. mailto:, tel:, #anchors and relative URLs are not touched.
  • An anchor with a data-ray-no-track attribute is skipped. Use it for unsubscribe and legal links:
{
  "bodyHtml": "<p><a href=\"https://shop.example.com/sale\">Shop the sale</a></p><p><a data-ray-no-track href=\"https://shop.example.com/unsubscribe?u={{userId}}\">Unsubscribe</a></p>"
}

Each tracked link points to Ray's redirect endpoint, GET /c/{token}. The token is HMAC-signed and carries the destination URL, your workspace, the sendId and the campaignId. The endpoint records the click and answers 302 to the destination. Because the destination comes only from the signed token, the redirect cannot be abused as an open redirect; a tampered or invalid token returns 404. The redirect endpoint needs no API key, since recipients are not authenticated.

With fan-out (targets[]), all recipients share one rendered body, so clicks are attributed to the send, not to an individual recipient. For per-recipient attribution, send one request per recipient and group them with campaignId.

Read click stats

GET /clicks requires at least one of sendId or campaignId. With both, both filters apply.

curl "https://ray-api.gege.mn/clicks?campaignId=spring-sale-2026" \
  -H "Authorization: Bearer $RAY_API_KEY"
{
  "totalClicks": 42,
  "byUrl": [
    { "url": "https://shop.example.com/sale", "clicks": 37 },
    { "url": "https://shop.example.com/faq", "clicks": 5 }
  ]
}

byUrl is sorted by clicks, highest first.

async function clickStats(query: { sendId?: string; campaignId?: string }) {
  const url = new URL("https://ray-api.gege.mn/clicks");
  if (query.sendId) url.searchParams.set("sendId", query.sendId);
  if (query.campaignId) url.searchParams.set("campaignId", query.campaignId);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.RAY_API_KEY}` } });
  if (!res.ok) throw new Error(`GET /clicks failed: ${res.status} ${await res.text()}`);
  return (await res.json()) as { totalClicks: number; byUrl: Array<{ url: string; clicks: number }> };
}
Status error message
400 validation_error one of `sendId` or `campaignId` is required
400 validation_error `sendId` must be a UUID

Caveats

  • Counts are total clicks, not unique clicks.
  • Counts include automated clicks: corporate mail security scanners often fetch every link in a message. Treat the numbers as a trend signal.
  • There is no open tracking (no tracking pixel).
PreviousStatus and feedsNextDelivery webhooks

On this page

  • Enable it on a send
  • What gets rewritten
  • Read click stats
  • Caveats
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