Webhooks
Get signed JSON at your own address when something happens, instead of polling the API.
A webhook makes RoWarden send a signed JSON request to your own https address when something happens in an experience: a ban, a warning, a record, a promotion and more. Use it to keep a Discord channel, a spreadsheet or another system up to date without asking the API over and over.
Webhooks are part of the API, so every plan has them. Add them under Outgoing webhooks in the experience menu. By default only the owner can, because a webhook leaves RoWarden: it needs the Manage outgoing webhooks permission.
What a request looks like
{
"id": "4812",
"api_version": "v1",
"type": "moderation.ban",
"experience_id": "34",
"occurred_at": "2026-11-04T10:00:00.000Z",
"actor": { "label": "api:Moderation bot", "source": "api" },
"target": { "type": "roblox_user", "id": "156" },
"data": { "record_id": "9", "username": "Builderman", "reason": "Exploiting", "display_reason": null, "duration_seconds": 86400, "status": "completed" }
}Headers: X-RoWarden-Event (the type), X-RoWarden-Delivery (the id), X-RoWarden-Signature and User-Agent: RoWarden-Webhooks/1. If your own API call caused the event, actor.source is api: ignore those if reacting to them would start a loop.
Verify the signature
Anyone can post to your address, so check every request. The header is
X-RoWarden-Signature: t=1793738471,v1=3c9f…v1 is the hex HMAC-SHA256 of the text <t>.<raw request body>, made with your signing secret (whsec_…, shown once when you add the webhook). Use the raw body, before any JSON parsing. Reject requests whose t is more than five minutes old. After a secret is replaced the header holds two v1= values for 24 hours, so accept a request if any of them matches.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody, header, secret) {
const parts = header.split(',');
const t = parts.find((p) => p.startsWith('t='))?.slice(2);
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return parts.filter((p) => p.startsWith('v1=')).some((p) => {
const given = p.slice(3);
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
});
}import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = header.split(",")
t = next((p[2:] for p in parts if p.startswith("t=")), None)
if not t or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(p[3:], expected) for p in parts if p.startswith("v1="))Roblox servers cannot receive web requests, so webhooks go to your own server. To pass something on to a running game, publish a message from there with Roblox's MessagingService Open Cloud API.
Managing webhooks from your own program
With the webhooks.manage permission a key can add, change and delete webhooks, send tests and read or retry deliveries (see the reference). Two things are different from the other endpoints:
- The signing secret is in the answer once, when you add a webhook or replace its secret. It is never kept for an
Idempotency-Keyreplay: repeating that request answers409instead of showing the secret again, so keep it safely the first time or replace it. - A webhook can send every event of the experience to an address of your choice, so give this permission only to keys that need it. The address cannot be changed afterwards: delete the webhook and add a new one.
Delivery
- Your server answers with any
2xxwithin 10 seconds. Redirects are not followed. - Otherwise RoWarden tries again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then marks the delivery failed. You can retry a failed one by hand, and the last deliveries are listed with each webhook for 14 days.
- A webhook that fails 20 times in a row is switched off and the people who manage webhooks are notified.
- Requests can arrive twice or out of order. Use
X-RoWarden-Deliveryto ignore repeats. - Only public https addresses are accepted. Addresses on private or internal networks are refused when you add them and again at every connection.
- Up to five webhooks per experience. Send a test delivers a
ping.
Choosing events
Tick whole groups, single events, or nothing for everything. Under the original body format every audit action in those groups is sent as it is; with v1 only the events below are sent, so a new internal action never reaches your server unannounced.
Staff, teams and promotions (group staff)
staff.invitedstaff.invitation_acceptedstaff.invitation_resentstaff.invitation_revokedstaff.leftstaff.reinstatedstaff.revokedstaff.role_addedstaff.role_assignedstaff.role_removedstaff.roles_updatedstaff.roblox_account_roles_updatedstaff.suspendedstaff.sync_exclusions_updatedstaff.whitelistedstaff.whitelist_claimedstaff.activity_visibility_updatedteam.createdteam.updatedteam.deletedteam.members_changedpromotion.nominatedpromotion.votedpromotion.withdrawnpromotion.decided
Staff records (group records)
record.issuedrecord.voidedrecord.template_savedrecord.template_deleted
Time off (group timeoff)
timeoff.requestedtimeoff.canceledtimeoff.decided
Sessions and shifts (group sessions)
session.createdsession.canceledsession.bookedsession.unbookedsession.attendance_markedsession.attendance_verifiedsession.series_createdsession.series_updatedsession.series_deletedsession.board_postedsession.board_removed
Assignments and quotas (group assignments)
assignment.createdassignment.updatedassignment.deletedassignment.ticked
Orders (group orders)
order.createdorder.statusorder.assigned
Moderation (group moderation)
moderation.ban(stable fields)moderation.unban(stable fields)moderation.kick(stable fields)moderation.warn(stable fields)moderation.note(stable fields)moderation.bulkmoderation.record_editedmoderation.exportedmoderation.approval_requestedmoderation.watchlist_addedmoderation.watchlist_removed
Events marked stable fields have exactly these fields in data: record_id, username, reason, display_reason, duration_seconds, status.
Moderation events have a promised data shape. For the other events data holds the details of the audit entry; they can gain fields, so read the ones you need and ignore the rest.
Body formats
Webhooks created before the API existed keep sending the original body (event, experienceId, occurredAt, numeric id). New ones use v1. Switch a webhook to v1 when your receiver is ready: the setting is on its page.