Platform
Learn
Developer docs User guide Quickstart Blog
Company
Services About Contact Links Get started

Webhooks

Updated

A webhook makes Raytha send an HTTP POST to your URL whenever something you subscribed to happens: a content item is published, a user is created, a role is deleted. Every request is signed with a secret that only you and Raytha know, so your endpoint can tell that it came from your site and was not altered.

Create a webhook

  1. Open Automation, Webhooks in the admin sidebar and select New webhook. You need the Manage System Settings permission.
  2. Fill in the form:
    FieldMeaningDefault and limits
    NameA label for you.Required, up to 200 characters.
    URLWhere Raytha sends the request.Absolute http:// or https:// URL. Use HTTPS.
    DescriptionOptional notes.
    Subscribed eventsComma-separated event names, or * for all events.Names must exist in the list below.
    Max attemptsTotal tries before the delivery is marked failed.5, from 1 to 10.
    Timeout (seconds)How long Raytha waits for your response.30, from 1 to 120.
    ActiveClear it to pause the webhook without deleting it.On.
  3. Save. A "Webhook created" screen shows the signing secret once: "Copy it now. It will not be shown again." Store it in your receiver's configuration.

The secret is 64 hexadecimal characters (32 random bytes). Raytha cannot show it again and there is no rotate action. To change it, create a new webhook, switch your receiver over, and delete the old one.

Events

Raytha publishes an event after a change succeeds. A command that fails publishes nothing. The names you can subscribe to:

GroupEvents
Administratorsadmin.created, admin.updated, admin.deleted
Contentcontent_item.created, content_item.updated, content_item.deleted, content_item.restored, content_item.unpublished, content_item.settings_updated, content_item.templates_assigned, content_item.bulk_deleted
Content typescontent_type.created, content_type.updated, content_type.deleted
Mediamedia_item.created, media_item.deleted
Rolesrole.created, role.updated, role.deleted
Site pagessite_page.created, site_page.updated, site_page.deleted, site_page.published, site_page.unpublished
Usersuser.created, user.updated, user.deleted, user.active_changed
Testwebhook.test, sent only when you trigger a test. It needs no subscription.

The current list is also available from GET /raytha/api/admin/webhooks/events.

What Raytha sends

A POST with a JSON body and these headers:

HeaderValue
Content-Typeapplication/json; charset=utf-8
X-Raytha-EventThe event name, such as content_item.created.
X-Raytha-DeliveryThe delivery's unique id (a GUID). It is the same on every retry of the same delivery. Use it to deduplicate.
X-Raytha-TimestampThe time of this attempt in ISO 8601 round-trip format, for example 2026-10-02T16:47:59.5801790Z. Each retry has a new value.
X-Raytha-Signaturesha256= followed by the lowercase hex HMAC-SHA256 of <timestamp>.<raw body>.

The body is an envelope. This is a content_item.created delivery (the delivery id is illustrative):

{
  "id": "287aeb31-0a5d-4a6e-9fd0-5d9b0b6e57f1",
  "event": "content_item.created",
  "timestamp": "2026-10-02T16:54:56.8400607Z",
  "data": {
    "result": "LjuYVg-4Z02jv3Gfr58aLg",
    "request": {
      "saveAsDraft": false,
      "templateId": "EERjUcYv20a4jYfe3izXWA",
      "contentTypeDeveloperName": "products",
      "content": { "title": "Webhook item", "content": "<p>x</p>" }
    },
    "requestType": "ContentItems.Commands.CreateContentItem+Command"
  }
}
  • id equals X-Raytha-Delivery. timestamp is when the event was created, which can be earlier than the attempt time in the header.
  • data.result is what the operation returned, usually the id of the thing it created or changed.
  • data.request is the command that ran, serialised in camelCase. data.requestType names its type. These shapes are Raytha's internal commands, so treat unknown properties as optional.
  • Any property whose name contains password, secret, token, apikey or api_key is removed from request, at every depth. This includes keys inside your own content, so a content field called token will be missing from the payload.
  • Fetch the full current item through the REST API if the event does not carry everything you need.

The test event looks like this:

{
  "id": "5b0c2a52-8d3c-4c61-a3a3-3b1e8d1a9f10",
  "event": "webhook.test",
  "timestamp": "2026-10-02T16:47:30.1230000Z",
  "data": {
    "message": "Test event fired from the Raytha admin.",
    "webhookName": "My webhook",
    "triggeredBy": "[email protected]"
  }
}

Verify the signature

Reject any request whose signature you cannot reproduce. The recipe:

  1. Read the raw request body as bytes. Do not parse and re-serialise the JSON first; any change in whitespace or key order breaks the signature.
  2. Build the string <X-Raytha-Timestamp>.<body> using the header value exactly as received.
  3. Compute HMAC-SHA256 with the UTF-8 bytes of your secret as the key, and hex-encode it in lowercase.
  4. Compare sha256=<hex> with X-Raytha-Signature in constant time.
  5. Reject the request if the timestamp is more than a few minutes from your clock. That stops someone replaying a captured request.

A test vector to check your implementation. With secret whsec_example, timestamp 2026-10-02T12:00:00.0000000Z and this exact body:

{"id":"0d9c3f5e-7f4e-4b8a-9f57-1c2d3e4f5a6b","event":"webhook.test","timestamp":"2026-10-02T12:00:00Z","data":{"message":"hello"}}

the signature is:

sha256=8e471c5d41f59c57cf337673aa9acb5a43f2a51aa4d26b21f65dd9aa2399be64

JavaScript (Node.js and Express)

import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.RAYTHA_WEBHOOK_SECRET;
const MAX_AGE_MS = 5 * 60 * 1000;

function verifyRaythaSignature(rawBody, headers, secret) {
  const timestamp = headers["x-raytha-timestamp"];
  const signature = headers["x-raytha-signature"];
  if (!timestamp || !signature) return false;

  const age = Math.abs(Date.now() - Date.parse(timestamp));
  if (!Number.isFinite(age) || age > MAX_AGE_MS) return false;

  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

// Verify against the raw bytes, so do not use express.json() on this route.
app.post("/raytha-webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyRaythaSignature(req.body, req.headers, SECRET)) {
    return res.sendStatus(401);
  }
  const deliveryId = req.headers["x-raytha-delivery"];
  const event = JSON.parse(req.body.toString("utf8"));
  console.log(deliveryId, event.event, event.data);
  res.sendStatus(204);
});

app.listen(3000);

C# (ASP.NET Core minimal API)

using System.Globalization;
using System.Security.Cryptography;
using System.Text;

var secret = Environment.GetEnvironmentVariable("RAYTHA_WEBHOOK_SECRET")!;
var app = WebApplication.Create(args);

app.MapPost("/raytha-webhook", async (HttpRequest request) =>
{
    // Read the raw bytes. Do not let a model binder re-serialize the body.
    using var buffer = new MemoryStream();
    await request.Body.CopyToAsync(buffer);
    var body = buffer.ToArray();

    var timestamp = request.Headers["X-Raytha-Timestamp"].ToString();
    var signature = request.Headers["X-Raytha-Signature"].ToString();
    if (!IsValid(timestamp, signature, body, secret))
    {
        return Results.Unauthorized();
    }

    var deliveryId = request.Headers["X-Raytha-Delivery"].ToString();
    Console.WriteLine($"{deliveryId} {request.Headers["X-Raytha-Event"]}");
    return Results.NoContent();
});

app.Run();

static bool IsValid(string timestamp, string signature, byte[] body, string secret)
{
    if (!DateTimeOffset.TryParse(timestamp, CultureInfo.InvariantCulture,
            DateTimeStyles.RoundtripKind, out var sentAt)
        || (DateTimeOffset.UtcNow - sentAt).Duration() > TimeSpan.FromMinutes(5))
    {
        return false;
    }

    var prefix = Encoding.UTF8.GetBytes(timestamp + ".");
    var message = new byte[prefix.Length + body.Length];
    prefix.CopyTo(message, 0);
    body.CopyTo(message, prefix.Length);

    var hash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), message);
    var expected = "sha256=" + Convert.ToHexStringLower(hash);

    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(expected),
        Encoding.UTF8.GetBytes(signature));
}

Delivery, retries and ordering

  • Success is any 2xx response within the timeout. Respond quickly and do slow work afterwards; a response that takes longer than the timeout counts as a failure and triggers a retry.
  • Anything else is a failed attempt: a non-2xx status, a timeout, a connection error, or a blocked address.
  • Retries wait 2 s × 2n−1 after attempt n, capped at 60 seconds: 2, 4, 8, 16, 32, 60, 60, 60, 60. A scheduler checks about every 5 seconds, so real gaps are a little longer. The retry schedule is the same for every webhook; only Max attempts changes how many there are.
  • After the last attempt the delivery is marked failed and stays that way until someone redelivers it. Statuses are pending, succeeded and failed.
  • At least once. A delivery whose worker dies is picked up again after 10 minutes, so your endpoint may see the same X-Raytha-Delivery twice. Deduplicate on it.
  • No ordering guarantee. Deliveries run on background workers (NUM_BACKGROUND_WORKERS), so an updated event can arrive before its created. Compare data to the current state rather than trusting arrival order.
  • A failure to create a delivery never fails the change that caused it. The change is saved and the event may be lost.
  • Raytha blocks webhook URLs that resolve to localhost, private networks and cloud metadata addresses. The delivery fails with "Request to 127.0.0.1 blocked because it resolves to an internal network address." To deliver to such a target, for example a receiver on your own network, set ALLOW_INTERNAL_URL_IMPORTS=true.

See what happened

The admin shows webhooks, but not their deliveries. Two places have the details:

  • Settings, Background tasks. Each attempt is a task named Raytha.Application.Webhooks.DeliverWebhookTask. Its status text reads like Delivered to https://example.com/hook (HTTP 204). or Attempt 1 of 5 failed (...); retry in 2s at ....
  • The admin API has the full delivery log: payload, response status, response body (kept up to 2,000 characters), error message and duration.

The admin API uses your signed-in session cookie and requires Content-Type: application/json on writes. For scripts, sign in once and reuse the cookie:

curl -s -c jar.txt -X POST "$RAYTHA_URL/raytha/api/auth/login" \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"your-password"}'

# List recent failed deliveries
curl -s -b jar.txt "$RAYTHA_URL/raytha/api/admin/webhooks/deliveries?status=failed" \
  | jq '.items[] | {id, webhookName, eventName, attemptCount, responseCode, errorMessage}'

Filters on the list are webhookId, eventName and status (pending, succeeded or failed). Other calls, all on /raytha/api/admin/webhooks:

RequestWhat it does
POST /{id}/testSends a webhook.test event to that one webhook, whether or not it subscribes to it.
POST /deliveries/{id}/redeliverResets a delivery to pending, sets its attempt count to zero and queues it again with the original payload and the same delivery id. The timestamp and signature are new.
DELETE /deliveriesClears the delivery log.
GET, POST, PUT, DELETE on / and /{id}List, create, edit and delete webhooks. Create takes name, url, description, isActive, subscribedEvents, maxAttempts and timeoutSeconds and returns {"id", "secret"}.

Deleting a webhook deletes its delivery history, and pending deliveries are not sent. Delivery rows are also purged automatically after the retention period, 180 days by default. Change it under Settings, Maintenance, Data retention.

Test it end to end

  1. Run a receiver, such as one of the examples above, and expose it with a tunnel or a public host name. If you test against a receiver on your own machine or network, start Raytha with ALLOW_INTERNAL_URL_IMPORTS=true.
  2. Create a webhook for it and save the secret in RAYTHA_WEBHOOK_SECRET.
  3. Fire a test: curl -s -b jar.txt -X POST "$RAYTHA_URL/raytha/api/admin/webhooks/<id>/test" -H 'Content-Type: application/json'.
  4. Check that your receiver answered 204, then look at the delivery list. It should show succeeded.
  5. Change a character in the secret and send another test. It should fail with 401 and show as a failed attempt in the delivery log.

Gotchas

  • Sign the bytes you received. Frameworks that parse JSON before your handler runs (Express with express.json(), ASP.NET model binding) hand you a re-serialised body that no longer matches.
  • The secret is stored in plain text in the Webhooks table, so database backups contain it. See Backups and restores.
  • The signature changes on every attempt because the timestamp is part of it. Do not cache a signature to compare against later.
  • Redirects. Register the final URL. A redirect from http:// to https:// is not a reliable way to receive a signed POST.
  • Clock drift. If your server's clock is more than your tolerance away from Raytha's, valid requests are rejected. Keep both on NTP.

Next steps