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
| Hook | When it runs | Effect on the send | Use it for |
|---|---|---|---|
| Presend | Before the message is accepted | Blocking. It can reject the message or rewrite its content. | Cheap, local, deterministic checks: blocklists, regular expressions, length caps, per-user rate limits |
| Postsend | After the message is accepted, with its seq | None. 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:
sendAsUserthrowsPresendRejectedError(Node), andSendAsUserAsyncthrowsPresendRejectedException(.NET).- The client-send handlers and the webhook path return
422with{ "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 gets422 presend_rejectedwith 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 with503 presend_unavailable.Openaccepts 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
- Redaction: what happens to a flagged message.
- Configuration reference: webhooks