This commit is contained in:
Joe 2026-02-02 12:08:47 -06:00
parent a9b4f8e83b
commit e6efc04756
8494 changed files with 495610 additions and 0 deletions

View file

@ -0,0 +1,538 @@
---
title: Automate right to erasure requests
description: Explains how to automate Right to Erasure requests with webhooks and Open Cloud APIs for data stores.
---
The **General Data Protection Regulation (GDPR)** is a European regulation on data protection and privacy. It grants individuals the right to request the deletion of their personal data, known as the [right to erasure](https://gdpr-info.eu/art-17-gdpr/). If you store any **Personally Identifiable Information (PII)** of your users, such as their User IDs, you must comply with GDPR requirements by deleting this information upon receiving a user's request.
Instead of handling requests manually, you can [set up a webhook](../../cloud/webhooks/webhook-notifications.md) and use a bot within a third-party messaging application to automate the process. As [data stores](../../cloud-services/data-stores/index.md) being the most common way for storing PII data, this tutorial provides an example on how to create a bot within Guilded or Discord that uses the [Open Cloud API for data stores](../../cloud/guides/data-stores/index.md) to delete PII data as an automation solution.
## Workflow
Upon completing this tutorial, you should be able to create a locally-running custom program that automates the handling of right to erasure requests from users. The workflow for this process is as follows:
1. Roblox Support receives a right to erasure request from a user.
1. Roblox webhook is triggered, containing the User ID and a list of Start Place IDs for the experiences they have joined in the payload.
1. Your bot listens for these webhook notifications, verifies their authenticity, and utilizes the [Open Cloud API for data stores](../../cloud/guides/data-stores/index.md) to delete the PII data stored in data stores.
<Alert severity="warning">
To use this solution, make sure your data store keys are identifiable by User IDs, such as containing User IDs as substrings, or you need to modify the scripts to match your own data schema.
</Alert>
1. The bot responds to the webhook message in Discord or Guilded with the deletion status.
<img src="../../assets/misc/Webhooks-Bot-Workflow.png" width="100%" />
## Configure a webhook with third-party integration
Before creating a bot, set up a server with webhook integration on the third-party messaging application. Then use the server to configure a webhook on Creator Dashboard.
### Set up a server
The following steps show how to set up the server using Guilded or Discord.
<Tabs>
<TabItem label="Guilded">
1. Create a new Guilded server. If you are unfamiliar with the process, see [Guilded Support](https://support.guilded.gg/hc/en-us/articles/1500002751582-Create-a-Server).
1. Under the **Privacy** settings, set the server to private. The server automatically creates a private **#general** channel as your default channel.
1. Create a webhook integration with the new server and give it a name that you can easily understand, such as `GDPR Hook`. If you are unfamiliar with the process, see [Guilded Support](https://support.guilded.gg/hc/en-us/articles/360038927934-Incoming-Webhooks).
1. Copy the webhook URL and store it in a secure place. Only allow trusted team members to access it, as leaking the URL can enable bad actors to send fake messages and potentially delete your user data.
</TabItem>
<TabItem label="Discord">
1. Create a new Discord server. If you are unfamiliar with the process, see [Discord Support](https://support.discord.com/hc/en-us/articles/204849977-How-do-I-create-a-server-).
<Alert severity="info">
It's recommended to set your server as a private server to protect user security. See [Discord Support](https://support.discord.com/hc/en-us/articles/206143407-How-do-I-set-up-a-private-server-) if you are unfamiliar with the process.
</Alert>
1. The server automatically creates a **#general** channel as your default channel. Click the **Edit Channel** icon of the **#general** channel.
1. Under **Permissions**, set the channel to private.
1. Create a webhook integration with the new server, name it to `GDPR Hook`. If you are unfamiliar with the process, see [Discord Support](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks).
1. Copy the webhook URL and store it in a secure place. Only allow trusted team members to access it, as leaking the URL can enable bad actors to send fake messages and potentially delete your user data.
</TabItem>
</Tabs>
### Configure a webhook on Roblox
After obtaining the third-party server URL, use it to [configure a webhook](../../cloud/webhooks/webhook-notifications.md#configure-webhooks-on-creator-dashboard) on Creator Dashboard. make sure you perform the following settings:
<Alert severity="info">
Currently, only group owners can receive Right to Erasure requests for group-owned experiences. To implement the automation solution for a group-owned experience, make sure that the group owner configures the webhook.
</Alert>
- Add the Guilded or Discord server URL as the **Webhook URL**.
- Include a custom **Secret**. Though a secret is optional for completing the configuration, you should include one to prevent bad actors from impersonating Roblox and deleting your data. For more information on the usage of a secret, see [Verify webhook security](../../cloud/webhooks/webhook-notifications.md#verifying-webhook-security).
- Select **Right to Erasure Request** under **Triggers**.
You can test the webhook using the **Test Response** button to see if you receive a notification in your server's **#general** channel from Roblox. If you don't receive the notification, try again or check your server settings to troubleshoot the error.
<img src="../../assets/misc/Webhooks-Sample-Notification.png" width="50%" alt="Example notification on Guilded"/>
## Configure a bot
After you add the webhook, use it to configure the bot with the following steps:
<Tabs>
<TabItem label="Guilded">
1. Open the **All servers** list by clicking its icon or use the shortcut:
- <kbd>Ctrl</kbd><kbd>S</kbd> on Windows.
- <kbd>⌘</kbd><kbd>S</kbd> on Mac.
1. Select your server for receiving right to erasure notifications.
1. Expand the list under **Server home** and select **Manage Bots**.
1. The server automatically creates a private **#general** channel as your default channel.
1. Click the **Create a bot** button and add a bot name. Guilded redirects you to the bot configuration page.
1. Select the **API** section on the bot configuration page.
1. Under the **Tokens** section, click the **Generate Token** button.
1. Save and store the generated token in a safe place.
</TabItem>
<TabItem label="Discord">
Check out Discord's [documentation](https://discord.com/developers/docs/topics/oauth2#bot-vs-user-accounts) for latest instructions.
1. Navigate to the [Applications page](https://discord.com/developers/applications).
1. Create a new application and name it to `GDPR Bot`.
1. The system redirects you to the **General Information** settings of the bot. Copy and save its application ID in a secure place.
1. Under the settings menu, select **OAuth2**.
1. Navigate to the **OAuth2** settings
1. Enable the **bot** scope, a list of additional Bot Permissions displays.
2. Add the **Administrator** permission. Save your changes.
3. Save the generated URL.
1. Copy the generated URL. Leave the application settings page unclosed.
1. Navigate to the generated URL. Select the target server. Click the **Continue** button and then the **Authorize** button.
1. Navigate back to application settings page and navigate to the Bot settings.
1. Under the **Privileged Gateway Intents** section, enable **Message Content Intent**.
1. In the Bot settings > **Build-A-Bot** section, save the bot token in a secure place for later steps. If you don't see the token, click the **Reset Token** button to generate a new one.
</TabItem>
</Tabs>
## Create an Open Cloud API key
To allow your third-party bot to access your data stores for storing PII data of users, [create an Open Cloud API key](../auth/api-keys.md) that can access your experiences and add the **Delete Entry** permission of data stores for data deletion. If you use ordered data stores for storing PII, you also need to add the **Write** permission of ordered data stores. After completion, copy and save the API key in a secure location to use it in later steps.
## Obtain identifiers of experiences and places
For the bot to locate the PII data requested by users for deletion, obtain the following identifiers of all experiences that you intend to use the bot for:
- The **Universe ID**, the unique identifier of your experience.
- The **Start Place ID**, the unique identifier of the start place of an experience.
To obtain these identifiers:
1. Navigate to the [Creator Dashboard](https://create.roblox.com/dashboard/creations).
2. Hover over an experience's thumbnail, click the **&ctdot;** button, and select **Copy Universe ID** and **Copy Start Place ID**, respectively.
<img src="../../assets/creator-dashboard/Options-Button-Experience-Public.png" width="200" />
## Add scripts
After you finish setting up the webhook, bot, and API key for data stores, add them to the scripts that implement the bot's automation logic. The following example uses Python 3:
1. Install Python libraries using the following commands:
<Tabs>
<TabItem label="Guilded">
```bash title="Install Libraries"
pip3 install guilded.py==1.8.0
pip3 install requests
pip3 install urllib3==1.26.6
```
</TabItem>
<TabItem label="Discord">
```bash title="Install Libraries"
pip3 install discord
pip3 install requests
pip3 install urllib3==1.26.6
```
</TabItem>
</Tabs>
1. Copy and save the following scripts corresponding to different parts of the bot logic in the same directory:
```python title="bot_config.py"
BOT_TOKEN = ""
OPEN_CLOUD_API_KEY = ""
ROBLOX_WEBHOOK_SECRET = ""
# Dictionary of the Start place ID to
# (universe ID, list of (data stores name, scope, and entry key)) for
# Standard Data Stores
# User data stored under these entries will be deleted
STANDARD_DATA_STORE_ENTRIES = {
# Start Place ID
111111111: (
# Universe ID
222222222,
[
("StandardDataStore1", "Scope1", "Key1_{user_id}"),
("StandardDataStore1", "Scope1", "Key2_{user_id}"),
("StandardDataStore2", "Scope1", "Key3_{user_id}")
]
),
33333333: (
444444444,
[
("StandardDataStore3", "Scope1", "Key1_{user_id}")
]
)
}
# Dictionary of the Start place ID to
# (universe ID, list of (data stores name, scope, and entry key)) for
# Ordered Data Stores
# User data stored under these entries will be deleted
ORDERED_DATA_STORE_ENTRIES = {
111111111: (
222222222,
[
("OrderedDataStore1", "Scope2", "Key4_{user_id}")
]
)
}
```
```python title="data_stores_api.py"
import requests
import bot_config
from collections import defaultdict
"""
Calls Data Stores Open Cloud API to delete all entries for a user_id configured in
STANDARD_DATA_STORE_ENTRIES. Returns a list of successful deletions and failures to delete.
"""
def delete_standard_data_stores(user_id, start_place_ids):
successes = defaultdict(list)
failures = defaultdict(list)
for owned_start_place_id in bot_config.STANDARD_DATA_STORE_ENTRIES:
if owned_start_place_id not in start_place_ids:
continue
universe_id, universe_entries = bot_config.STANDARD_DATA_STORE_ENTRIES[owned_start_place_id]
for (data_store_name, scope, entry_key) in universe_entries:
entry_key = entry_key.replace("{user_id}", user_id)
response = requests.delete(
f"https://apis.roblox.com/datastores/v1/universes/{universe_id}/standard-datastores/datastore/entries/entry",
headers={"x-api-key": bot_config.OPEN_CLOUD_API_KEY},
params={
"datastoreName": data_store_name,
"scope": scope,
"entryKey": entry_key
}
)
if response.status_code in [200, 204]:
successes[owned_start_place_id].append((data_store_name, scope, entry_key))
else:
failures[owned_start_place_id].append((data_store_name, scope, entry_key))
return successes, failures
"""
Calls Ordered Data Stores Open Cloud API to delete all entries for a user_id configured in
ORDERED_DATA_STORE_ENTRIES. Returns a list of successful deletions and failures to delete.
"""
def delete_ordered_data_stores(user_id, start_place_ids):
successes = defaultdict(list)
failures = defaultdict(list)
for owned_start_place_id in bot_config.ORDERED_DATA_STORE_ENTRIES:
if owned_start_place_id not in start_place_ids:
continue
universe_id, universe_entries = bot_config.ORDERED_DATA_STORE_ENTRIES[owned_start_place_id]
for (data_store_name, scope, entry_key) in universe_entries:
entry_key = entry_key.replace("{user_id}", user_id)
response = requests.delete(
f"https://apis.roblox.com/ordered-data-stores/v1/universes/{universe_id}/orderedDatastores/{data_store_name}/scopes/{scope}/entries/{entry_key}",
headers={"x-api-key": bot_config.OPEN_CLOUD_API_KEY}
)
if response.status_code in [200, 204, 404]:
successes[owned_start_place_id].append((data_store_name, scope, entry_key))
else:
failures[owned_start_place_id].append((data_store_name, scope, entry_key))
return successes, failures
```
```python title="message_parser.py"
import time
import hmac
import hashlib
import re
import base64
import bot_config
"""
Parses received message for Roblox signature and timestamp, the footer is only set if you
configured webhook secret
"""
def parse_footer(message):
if not message.embeds[0].footer or \
not message.embeds[0].footer.text:
return "", 0
footer_match = re.match(
r"Roblox-Signature: (.*), Timestamp: (.*)",
message.embeds[0].footer.text
)
if not footer_match:
return "", 0
else:
signature = footer_match.group(1)
timestamp = int(footer_match.group(2))
return signature, timestamp
"""
Verifies Roblox signature with configured secret to check for validity
"""
def validate_signature(message, signature, timestamp):
if not message or not signature or not timestamp:
return False
# Prevents replay attack within 300 seconds window
request_timestamp_ms = timestamp * 1000
window_time_ms = 300 * 1000
oldest_timestamp_allowed = round(time.time() * 1000) - window_time_ms
if request_timestamp_ms < oldest_timestamp_allowed:
return False
# Validates signature
timestamp_message = "{}.{}".format(timestamp, message.embeds[0].description)
digest = hmac.new(
bot_config.ROBLOX_WEBHOOK_SECRET.encode(),
msg=timestamp_message.encode(),
digestmod=hashlib.sha256
).digest()
validated_signature = base64.b64encode(digest).decode()
if signature != validated_signature:
return False
# Valid signature
return True
"""
Parses a received webhook messaged on Discord or Guilded. Extracts user ID, prevents replay attack
based on timestamp received, and verifies Roblox signature with configured secret to check for
validity.
"""
def parse_message(message):
# Parses received message for user ID and game ID
if len(message.embeds) != 1 or \
not message.embeds[0].description:
return "", []
description_match = re.match(
r"You have received a new notification for Right to Erasure for the User Id: (.*) in " +
r"the game\(s\) with Ids: (.*)",
message.embeds[0].description
)
if not description_match:
return "", []
user_id = description_match.group(1)
start_place_ids = set(int(item.strip()) for item in description_match.group(2).split(","))
signature, timestamp = parse_footer(message)
if validate_signature(message, signature, timestamp):
return user_id, start_place_ids
else:
return "", []
```
<Tabs>
<TabItem label="Guilded">
```python title="guilded_bot.py"
import guilded
import json
import bot_config
import data_stores_api
import message_parser
def run():
client = guilded.Client()
@client.event
async def on_ready():
print(f"{client.user} is listening to Right to Erasure messages")
"""
Handler for webhook messages from Roblox
"""
@client.event
async def on_message(message):
# Parses and validates message
user_id, start_place_ids = message_parser.parse_message(message)
if not user_id or not start_place_ids:
return
# Deletes standard data stores user data
[successes, failures] = data_stores_api.delete_standard_data_stores(user_id, start_place_ids)
if successes:
await message.reply(f"Deleted standard data stores data for " +
f"user ID: {user_id}, data: {dict(successes)}")
if failures:
await message.reply(f"Failed to delete standard data stores data for " +
f"user ID: {user_id}, data: {dict(failures)}")
# Deletes ordered data stores user data
[successes, failures] = data_stores_api.delete_ordered_data_stores(user_id, start_place_ids)
if successes:
await message.reply(f"Deleted ordered data stores data for " +
f"user ID: {user_id}, data: {dict(successes)}")
if failures:
await message.reply(f"Failed to delete ordered data stores data for " +
f"user ID: {user_id}, data: {dict(failures)}")
client.run(bot_config.BOT_TOKEN)
if __name__ == "__main__":
run()
```
</TabItem>
<TabItem label="Discord">
```python title="discord_bot.py"
import discord
import bot_config
import data_stores_api
import message_parser
def run():
intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)
@client.event
async def on_ready():
print(f"{client.user} is listening to Right to Erasure messages")
"""
Handler for webhook messages from Roblox
"""
@client.event
async def on_message(message):
# Parses and validates message
user_id, start_place_ids = message_parser.parse_message(message)
if not user_id or not start_place_ids:
return
# Deletes standard data stores user data
[successes, failures] = data_stores_api.delete_standard_data_stores(user_id, start_place_ids)
if successes:
await message.reply(f"Deleted standard data stores data for " +
f"user ID: {user_id}, data: {dict(successes)}")
if failures:
await message.reply(f"Failed to delete standard data stores data for " +
f"user ID: {user_id}, data: {dict(failures)}")
# Deletes ordered data stores user data
[successes, failures] = data_stores_api.delete_ordered_data_stores(user_id, start_place_ids)
if successes:
await message.reply(f"Deleted ordered data stores data for " +
f"user ID: {user_id}, data: {dict(successes)}")
if failures:
await message.reply(f"Failed to delete ordered data stores data for " +
f"user ID: {user_id}, data: {dict(failures)}")
client.run(bot_config.BOT_TOKEN)
if __name__ == "__main__":
run()
```
</TabItem>
</Tabs>
1. On the `bot_config.py` file for main configuration of the bot:
1. Set `BOT_TOKEN` to the token generated by your bot.
2. Set `OPEN_CLOUD_API_KEY` as the API key you created.
3. Set `ROBLOX_WEBHOOK_SECRET` as the secret you set when configuring the webhook on Creator Dashboard.
4. In `STANDARD_DATA_STORE_ENTRIES` and `ORDERED_DATA_STORE_ENTRIES` dictionaries for locating the data store of each record to delete:
1. Add your copied Start Place IDs as keys.
2. Add Universe IDs as the first element of the tuple value.
3. Replace the second element of the tuple with the name, scope, entry key name, and associated User ID of your data stores. If you use a different data schema, modify to match your own data schema accordingly.
1. Execute the following command to run the bot:
<Tabs>
<TabItem label="Guilded">
```bash title="Run Guilded Bot"
python3 guilded_bot.py
```
</TabItem>
<TabItem label="Discord">
```bash title="Run Discord Bot"
python3 discord_bot.py
```
</TabItem>
</Tabs>
1. The bot then starts to listen and verify Roblox webhooks for right to erasure Requests and calls the Open Cloud endpoint for deleting the corresponding data store.
<Alert severity="warning">
To ensure constant and secure execution of the scripts, save and run them locally only. Keep your local device or virtual machine running the scripts turned on at all times. In the event that your device goes offline, you need to manually manage any missed messages during the offline period and handle delivery failures according to the [retry policy](../../cloud/webhooks/webhook-notifications.md#delivery-failure-retry-policy).
</Alert>
## Test
You can create and run a test message to verify that your custom program can properly handle right to erasure requests and delete PII data:
1. Send an HTTP `POST` request to your Guilded or Discord webhook server with the following request body:
```bash title="Example Request"
curl -X POST {serverUrl}
-H 'Content-Type: application/json'
-d '{
"embeds":[{
"title":"RightToErasureRequest",
"description":"You have received a new notification for Right to Erasure for the User Id: {userIds} in the game(s) with Ids: {gameIds}",
"footer":{
"icon_url":"https://create.roblox.com/dashboard/assets/webhooks/roblox_logo_metal.png",
"text":"Roblox-Signature: {robloxSignature}, Timestamp: {timestamp}"
}
}]
}'
```
1. If you have a webhook secret:
1. Generate a `Roblox-Signature` by applying HMAC-SHA256 encoding to your webhook secret key.
2. Set the current time using UTC timestamp in seconds as `Timestamp`.
1. Put together the `description` in the following format:
```plain title="Description Field Format"
{Timestamp}. You have received a new notification for Right to Erasure for the User Id: {userId} in the game(s) with Ids: {gameIds}`.
```
For example:
```plain title="Example Description Field"
1683927229. You have received a new notification for Right to Erasure for the User Id: 2425654247 in the game(s) with Ids: 10539205763, 13260950955
```
Your program should be able to identify that your message is from the official Roblox source since you encoded the message with your secret. It should then delete the PII data associated with your request.
```json title="Example Body"
{
"embeds": [
{
"title": "RightToErasureRequest",
"description": "You have received a new notification for Right to Erasure for the User Id: 2425654247 in the game(s) with Ids: 10539205763, 13260950955",
"footer": {
"icon_url": "https://create.roblox.com/dashboard/assets/webhooks/roblox_logo_metal.png",
"text": "Roblox-Signature: UIe6GJ78MHCmU/zUKBYP3LV0lAqwWRFR6UEfPt1xBFw=, Timestamp: 1683927229"
}
}
]
}
```

View file

@ -0,0 +1,269 @@
---
title: Webhook notifications
description: Explains how to set up webhooks to automate your notification management workflow.
---
Instead of manually monitoring all events in your experience and requests from users, you can set up webhooks to receive real-time notifications on a third-party messaging tool or your custom endpoint that can receive HTTP requests. This helps you automate your notification management workflow to reduce manual effort handling notifications.
<Alert severity="info">
Currently, Roblox fully supports webhook notifications for Discord, Guilded, and Slack. Using webhooks with other third-party tools carries the risk of not receiving all notifications.
</Alert>
## Webhook workflow
Webhooks send real-time notifications or data between two different applications or services, such as Roblox and a third-party messaging tool. Unlike traditional APIs, which require you to set up a client application to send requests to a server to receive data, webhooks send data to your client endpoint as soon as an event occurs. They're useful for automating workflows between Roblox and third-party applications that you use for collaborating with your team, as they allow for real-time data sharing and processing.
Once you set up a webhook, whenever a target event occurs, Roblox sends a request to the webhook URL you provide. The webhook URL then redirects the request to your receiving application or custom endpoint, which can take action based on the data included in the webhook payload. This could include erasing data for GDPR, sending a confirmation to the user, or triggering another event.
## Supported triggers
Roblox currently supports the following event triggers.
### Subscription
- **Subscription Resubscribed** - When a user resubscribes to a subscription, a message is sent containing the subscription and subscriber.
- **Subscription Renewed** - When a user renews a subscription, a message is sent containing the subscription and subscriber.
- **Subscription Refunded** - When a user receives a refund for their subscription, a message is sent containing the subscription and subscriber.
- **Subscription Purchased** - When a user purchases a subscription, a message is sent containing the subscription and subscriber.
- **Subscription Cancelled** - When a user cancels a [subscription](../../production/monetization/subscriptions.md), a message is sent containing the subscription and subscriber, as well as the reason given for the cancellation.
For more information on subscription events and their fields, see the [Subscription](/cloud/reference/Subscription/) reference.
### Compliance
- **Right to Erasure Request** - When a user submits a data deletion request under the [General Data Protection Regulation](https://gdpr.eu/right-to-be-forgotten/) (GDPR).
### Commerce
- **Commerce Product Order Refunded** - When a user has received a refund for their commerce product order, or the order was cancelled.
- **Commerce Product Order Paid** - When a user has paid for their commerce product order. Please note that duplicate webhook events are possible, so you should deduplicate events using the unique commerce order ID.
## Configure webhooks on Creator Dashboard
To receive notifications through webhooks, you need to configure a webhook that subscribes to certain events for triggering notifications. For group-owned experiences, only group owners can configure and receive webhook notifications.
<Alert severity="info">
If you're setting up webhooks and handling personal data, ensure they comply with the [General Data Protection Regulation (GDPR)](https://gdpr-info.eu/).
</Alert>
To set up a webhook:
1. On the [Creator Dashboard](https://create.roblox.com/dashboard/creations), access the [Webhooks](https://create.roblox.com/settings/webhooks) page.
1. Click the **Add Webhook** button.
1. Complete the configuration fields:
1. **Webhook URL** - Specify the URL where you want to receive notifications. For more information on the requirements, see [Set up webhook URLs](#set-up-webhook-urls).
2. **Name** - Use a custom name to differentiate your configuration from others. By default the value is the same as the Webhook URL.
3. **Secret** (optional) - Supply a secret if you want to verify that notifications you receive are coming from Roblox. For more information, see [Verify webhook security](#verify-webhook-security).
4. **Triggers** - Choose one or more options from the list of [supported triggers](#supported-triggers) of events for which you want to receive notifications.
1. Click the **Save Changes** button.
<Alert severity="info">
Currently, you can configure up to 5 webhooks in total.
</Alert>
## Set up webhook URLs
You can set up a custom HTTP service endpoint as your webhook URL, provided it fulfills the following requirements:
- It must be publicly accessible for handling requests.
- It can handle POST requests.
- It can respond to the request with a 2XX response within 5 seconds.
- It can handle HTTPS requests.
When your endpoint receives a POST request, it must be able to:
- Extract the details required about the notification from the body of the POST message.
- Read the body of the POST message with the generic details on the notification and specific details related to the event type on the notification.
For more information of the schema of POST requests to handle, see the [Payload Schema](#payload-schema).
### Delivery failure retry policy
When a webhook notification fails to reach your specified URL due to errors such as endpoint unavailability, Roblox retries sending the message to the configured URL 5 times using a fixed window size. If the notification still fails to be delivered after 5 attempts, Roblox stops trying to send the notification and assumes that the URL is no longer valid. In this situation, you need to update your webhook configuration with a new URL that is reachable and able to receive notifications. To troubleshoot and confirm that your webhook URL can successfully receive notifications, see [Test webhooks](#test-webhooks).
### Third-party requirements
Third-party tools usually have their own requirements for webhooks that you need to follow when setting up your webhook URL. You can find these requirements by searching for the keyword "webhook" on the support or documentation site of the target tool. For the three supported third-party tools, see the following:
- [Discord Help Center](https://support.discord.com/hc/en-us)
- [Guilded Support](https://support.guilded.gg/hc/en-us)
- [Slack API Reference](https://api.slack.com/)
## Test webhooks
You can test whether the webhook you've configured can successfully receive notifications on the [Creator Dashboard](https://create.roblox.com/dashboard/creations):
1. Navigate to the [Webhooks](https://create.roblox.com/settings/webhooks) configuration page.
2. Select the webhook you want to test from the list of configured webhooks.
3. Click the pencil icon next to the target webhook.
4. Click the **Test Response** button.
The system then sends a `SampleNotification` event, which includes the **User ID** of the user who triggered the notification, as shown here:
```json title="SampleNotification schema"
{
"NotificationId": "string",
"EventType": "SampleNotification",
"EventTime": "2023-12-30T16:24:24.2118874Z",
"EventPayload": {
"UserId": 1
}
}
```
If you are integrating your webhook with a third-party service, you can test it using the third-party URL to confirm that the service can successfully receive notifications from your webhook. If you provide a secret when configuring the webhook, it also generates a `roblox-signature` that you can use to test the `roblox-signature` logic.
## Verify webhook security
After you configure your server to receive payloads, it starts to listen for any payload sent to the endpoint. If you set a secret when configuring your webhook, Roblox sends a `roblox-signature` in each webhook notification to ensure that the request actually came from Roblox. The signature is in the payload header for custom endpoints and in the footer for third-party servers.
```csv title="Signature format with a secret for custom endpoints"
t=<timestamp>,v1=<signature>
```
If you did not set a secret for your webhook, the signature only contains the timestamp of when the notification was sent:
```csv title="Signature format without a secret for custom endpoints"
t=<timestamp>
```
To verify a signature:
<Tabs>
<TabItem label="Custom endpoints">
1. Extract the timestamp and signature values. All signatures for webhooks with secrets share the same format as a CSV string with these two values following by the prefixes:
- `t`: The timestamp of when the notification was sent.
- `v1`: The signature value generated using the secret provided by the Creator Dashboard configuration.
1. Re-create the base string of `roblox-signature` by concatenating:
1. The timestamp as a string.
1. The period character `.`.
1. The JSON string of the request body.
1. Compute a hash-based message authentication code (HMAC) with the SHA256 hash function using the secret you defined during the configuration as the key and the base string you generated through step 2 as the message. Convert the result to Base64 format to get the expected signature.
1. Compare the extracted signature value to the expected signature. If you generated the signature correctly, the value should be the same.
1. (Optional) To prevent replay attacks, a type of cyber attack where attackers intercept and resend data to gain unauthorized access or perform malicious actions, it's helpful to compare the extracted timestamp value with the current timestamp and ensure it falls within a reasonable time limit. For example, a 10-minute window is usually a good reasonable time limit.
</TabItem>
<TabItem label="Third-party servers">
1. Extract the timestamp value from the `Timestamp` footer of the notification.
1. Extract the message body from the notification. For the following example notification, the message body is:
<figcaption>
You have received a new notification for Right to Erasure for the User Id: XXXXXXXX in the game(s) with Ids: YYYYYYY, ZZZZZZZZZ
</figcaption>
<img src="../../assets/misc/Webhooks-Example-Message-Body.png" width="50%" alt="Example message body on Guilded"/>
1. Concatenate the timestamp value and the message with a period character in the format as:
<figcaption>
168487229.You have received a new notification for Right to Erasure for the User Id: XXXXXXXX in the game(s) with Ids: YYYYYYY, ZZZZZZZZZ
</figcaption>
1. Compute a Hash-based message authentication code (HMAC) with the SHA256 hash function using the secret you defined during the configuration as the key and the base string you generated through step 2 as the message. Convert the result to Base64 format to get the expected signature.
1. Compare the extracted signature value to the expected signature. If you generated the signature correctly, the value should be the same.
1. **(Optional)** To prevent replay attacks, a type of cyber attack where attackers intercept and resend data to gain unauthorized access or perform malicious actions, it's helpful to compare the extracted timestamp value with the current timestamp and ensure it falls within a reasonable time limit. For example, a 10-minute window is usually a good reasonable time limit.
</TabItem>
</Tabs>
## Payload schema
When the target event of your webhook is triggered, it sends a request to your webhook URL, including information about the event in the payload. All payloads of requests share the same schema that consists of fixed and variable fields. This ensures that the data transmitted in the payload is structured and consistent, making it easier for the receiving application to process and use the data.
The **fixed payload schema fields** can help maintain consistency across all webhook requests, with the following fields available:
1. `NotificationId` (string): A unique identifier for each notification sent. If the same `NotificationId` is received twice, it is considered a duplicate.
2. `EventType` (string): Indicates the type of event for which the notification was triggered.
3. `EventTime` (string): The timestamp of when the event was triggered.
The **variable payload schema fields** provides flexibility for webhooks to accommodate various types of events, which include:
1. `EventPayload` (object): Contains information specific to the `EventType` that triggered the webhook. The structure of the `EventPayload` schema varies based on the type of event.
The following example shows the payload schema of the **Right To Erasure request** event:
```json title="Example schema for a Right to Erasure request"
{
"NotificationId": "string",
"EventType": "RightToErasureRequest",
"EventTime": "2023-12-30T16:24:24.2118874Z",
"EventPayload": {
"UserId": 1,
"GameIds": [
1234, 2345
]
}
}
```
## Handle notifications
If you store any **Personally Identifiable Information (PII)** of your users, such as their User IDs, you must delete this information when a user submits such a request to comply with the GDPR [right to erasure](https://gdpr-info.eu/art-17-gdpr/) compliance requirements. You can create a bot to handle webhook notifications and help automate data deletion, provided you're storing PII in a data store. See [Automating Right to Erasure Requests Deletion](../../cloud/webhooks/automate-right-to-erasure.md) for an example on how to create a bot within Guilded or Discord that uses the [Open Cloud API for data stores](../../cloud/guides/data-stores/index.md) to delete PII data as an automation solution. This example can be adapted for handling other notifications, such as subscription events.
If you use a custom endpoint as your webhook server instead of a third-party tool, you can extract the data subject to deletion from the webhook payload and build your own automation solution. The following code sample is an example of a server that has prevention against replay attacks by verifying the timestamp and that the request is coming from Roblox:
```javascript title="Extracting PII from Payload"
const crypto = require('crypto');
const express = require('express');
const secret = '<your_secret>' // This can be set as an environment variable
let app = express();
app.use(express.json());
app.all('/*', function (req, res) {
console.log('New request recieved');
// Extract the timestamp and signature from header
const signatureHeader = req.headers['roblox-signature'].split(',');
const timestamp = signatureHeader.find(e => e.startsWith('t=')).substring(2);
const signature = signatureHeader.find(e => e.startsWith('v1=')).substring(3);
// Ensure the request came within a 300 second window to prevent replay attacks
const requestTimestampMs = timestamp * 1000;
const windowTimeMs = 300 * 1000;
const oldestTimestampAllowed = Date.now() - windowTimeMs;
if (requestTimestampMs < oldestTimestampAllowed) {
return res.status(403).send('Expired Request');
}
// Validate signature
const message = `${timestamp}.${JSON.stringify(req.body)}`;
const hmac = crypto.createHmac('sha256', secret);
const calculatedSignature = hmac.update(message).digest('base64');
if (signature !== calculatedSignature) {
return res.status(401).send('Unauthorized Request');
}
// Your logic to handle payload
const payloadBody = req.body;
const eventType = payloadBody['EventType'];
if (eventType === 'RightToErasureRequest'){
const userId = payloadBody['EventPayload']['UserId'];
const gameIds = payloadBody['EventPayload']['GameIds'];
console.log(`Payload data: UserId=${userId} and GameIds=${gameIds}`);
// If you store PII in data stores, use the UserId and GameIds to delete the information from data stores.
}
return res.json({ message: 'Processed the message successfully' });
});
app.listen(8080, function () {
console.log('Server started');
});
```