Docs menu

Moderation webhooks

dotwire doesn't moderate content. It gives your host two hooks, and your host decides what to do with each message.

Two hooks, two speeds

HookWhen it runsEffect on the sendUse it for
PresendBefore the message is acceptedBlocking. It can reject the message or rewrite its content.Cheap, local, deterministic checks: blocklists, regular expressions, length caps, per-user rate limits
PostsendAfter the message is accepted, with its seqNone. The message is already sent.Handing off to asynchronous review, including anything that calls a network service

Postsend runs only for accepted messages, because only those have a (roomId, seq) pair that a later redaction can target.

Where the hooks run

You write the hooks once, as presend and postSend on @dotwire/host or Presend and PostSend on Dotwire.Host. The SDK runs them on every path a message can take through your host:

  • sends from your backend with sendAsUser / SendAsUserAsync,
  • sends that browsers make through your own route, handled by handleClientSend / HandleClientSendAsync,
  • dotwire's own webhook call-outs, handled by the presend and postsend webhook handlers.

The hooks never run for system messages you send with sendSystemMessage / SendSystemMessageAsync.

Browsers that send directly to dotwire bypass your host. To cover those sends, either set the client's sendUrl to your own route, or turn on dotwire's webhook call-outs.

Write the hooks

Node.js (@dotwire/host)

host.presend = async (ctx) =>
  Blocklist.hits(ctx.content) ? PresendResult.reject('blocked term') : PresendResult.accept();

const queue = new ModerationQueue();
host.postSend = queue.postSendHook;

PresendResult is exported from @dotwire/host. To rewrite content, return PresendResult.accept('<rewritten text>'). The helpers build plain objects, so returning { allow: false, rejectionReason: '...' } or { allow: true, content: '...' } directly works too.

.NET (Dotwire.Host)

client.Presend = (ctx, ct) => Task.FromResult(
    Blocklist.Hits(ctx.Content) ? PresendResult.Reject("blocked term") : PresendResult.Accept());

var queue = new ModerationQueue();          // bounded; back-pressures sends when full
client.PostSend = queue.PostSendHook;

Blocklist stands for your own check.

When presend rejects a message:

  • sendAsUser throws PresendRejectedError (Node), and SendAsUserAsync throws PresendRejectedException (.NET).
  • The client-send handlers and the webhook path return 422 with { "error": "presend_rejected", "reason": "..." }.

If postsend throws during SendAsUserAsync or sendAsUser, you get PostSendException or PostSendError, which carries the ack. The send already happened, so don't retry it.

Route browser sends through your host

Set sendUrl on @dotwire/client to a host route. The client then posts { roomId, content } to that route with credentials: 'include' and no bearer header. Hand the request to the SDK with the authenticated user's id:

Node.js (@dotwire/host)

const res = await host.handleClientSend(request, 'dave');

handleClientSend takes a Fetch API Request and returns a Response that mirrors dotwire's answer: 202 with { seq, time } on success. For frameworks without Fetch API requests, handleClientSendBody(body, userId) takes the parsed body and returns { status, body }.

.NET (Dotwire.Host)

app.MapPost("/my-app/rooms/{roomId}/send", async (HttpRequest req, string userId) =>
{
    var result = await client.HandleClientSendAsync(req.Body, userId);
    return Results.Text(result.Json, "application/json", statusCode: result.StatusCode);
});

Use dotwire's webhook call-outs

Instead of routing sends through your host, you can have dotwire call your host. Each call-out is on only when its URL is set.

Dotwire__Webhooks__Presend__Url=https://my-host.example.com/webhooks/dotwire/presend
Dotwire__Webhooks__PostSend__Url=https://my-host.example.com/webhooks/dotwire/postsend
Dotwire__Webhooks__Secret=<webhook-hmac-secret>

Set the same secret as webhookSecret (Node) or WebhookSecret (.NET) in your host SDK.

Handle the call-outs

Node.js (@dotwire/host)

const presendResponse = await host.handlePresendWebhook(request);
const postSendResponse = await host.handlePostSendWebhook(request);

Each takes a Fetch API Request and returns a Response. A bad signature gets 401. With no presend hook configured, the presend handler allows the message.

.NET (Dotwire.Host)

app.MapPost("/webhooks/presend", async (HttpRequest req) =>
{
    var result = await client.HandlePresendWebhookAsync(req.Body, req.Headers["X-Dotwire-Signature"]);
    return Results.Text(result.Json, "application/json", statusCode: result.StatusCode);
});

app.MapPost("/webhooks/postsend", async (HttpRequest req) =>
{
    var result = await client.HandlePostSendWebhookAsync(req.Body, req.Headers["X-Dotwire-Signature"]);
    return Results.Text(result.Json, "application/json", statusCode: result.StatusCode);
});

The postsend handlers swallow an exception from your postSend hook, because the message they describe was already sent.

Presend call-out

dotwire sends a POST with the header X-Dotwire-Event: presend and this body:

{
  "roomId": "a0000000-0000-0000-0000-000000000001",
  "senderId": "alice",
  "content": "<message text>",
  "time": "2026-11-06T12:00:00+00:00",
  "source": "member"
}

source is member for member sends, or admin for injected messages when IncludeAdminSends is on.

Your endpoint answers 200 with one of:

  • { "allow": true } to accept the message as is,
  • { "allow": true, "content": "<rewritten text>" } to accept it with new content,
  • { "allow": false, "reason": "<text>" } to reject it. The sender gets 422 presend_rejected with your reason.

Any other status, a body dotwire can't parse, or no answer within TimeoutMs (500 ms by default) counts as unavailable. Then FailPolicy decides:

  • Closed, the default, fails the send with 503 presend_unavailable.
  • Open accepts the original content.

Rewritten content is checked against the same size limit as the original.

Postsend call-out

After a message is accepted, dotwire queues a POST with the header X-Dotwire-Event: postsend. The body is the presend body plus the message's seq. The queue holds QueueCapacity items (10,000 by default); when it's full, dotwire drops the newest item and logs a warning. dotwire makes up to MaxAttempts delivery attempts per item (3 by default), each with a TimeoutMs of 2,000 ms by default. Postsend never blocks or fails a send.

Signatures

When Dotwire:Webhooks:Secret is set, both call-outs carry X-Dotwire-Signature: sha256=<hex>, an HMAC-SHA256 of the raw request body. The host SDK handlers verify it for you. If you write your own handler, verify it against the raw body before you parse it.

Admin sends

Injected system messages skip both call-outs unless you set IncludeAdminSends to true on that hook. They're host-authored and audited already.

Batched asynchronous review

Both host SDKs pair ModerationQueue with BatchModerationWorker. The worker drains the queue in batches bounded by size or time, asks a classifier for verdicts, and runs an action for each flagged message. The built-in action, redactWith, redacts through the admin API and retries on 429, honoring Retry-After.

dotwire ships no classifier. The samples/Moderation folder in the repository has reference classifiers that use Anthropic's Claude Message Batches API. They aren't built or tested as part of the repository. Copy one into your host and add the Anthropic SDK yourself.

Node.js (@dotwire/host)

const classifier = new ClaudeBatchModerationClassifier({ client: anthropic, policyPrompt });
const worker = new BatchModerationWorker(queue, classifier, BatchModerationWorker.redactWith(host));
await worker.run(abortController.signal);

.NET (Dotwire.Host)

IModerationClassifier classifier = new ClaudeBatchModerationClassifier(anthropic, policyPrompt);
var worker = new BatchModerationWorker(
    queue,
    classifier,
    onFlagged: BatchModerationWorker.RedactWith(client),
    new BatchModerationOptions { MaxBatchSize = 100, MaxBatchDelay = TimeSpan.FromSeconds(30) },
    onError: ex => logger.LogError(ex, "moderation"));

// e.g. inside a BackgroundService:
await worker.RunAsync(stoppingToken);

Batch verdicts arrive in minutes, not seconds. Use them as a second line of defense behind presend, never as the first.

Related