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. 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 and use a bot within a third-party messaging application to automate the process. As data stores 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 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:
- Roblox Support receives a right to erasure request from a user.
- Roblox webhook is triggered, containing the User ID and a list of Start Place IDs for the experiences they have joined in the payload.
- Your bot listens for these webhook notifications, verifies their authenticity, and utilizes the Open Cloud API for data stores to delete the PII data stored in data stores.
- The bot responds to the webhook message in Discord or Guilded with the deletion status.
Configuring 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.
Setting Up a Server
The following steps show how to set up the server using Guilded or Discord.
- Create a new Guilded server. If you are unfamiliar with the process, see Guilded Support.
- Under the Privacy settings, set the server to private. The server automatically creates a private #general channel as your default channel.
- 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.
- 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.
Configuring a Webhook on Roblox
After obtaining the third-party server URL, use it to configure a webhook on Creator Dashboard. make sure you perform the following settings:
- 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 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.
Configuring a Bot
After you add the webhook, use it to configure the bot with the following steps:
Open the All servers list by clicking its icon or use the shortcut:
- CtrlS on Windows.
- ⌘S on Mac.
Select your server for receiving right to erasure notifications.
Expand the list under Server home and select Manage Bots.
The server automatically creates a private #general channel as your default channel.
Click the Create a bot button and add a bot name. Guilded redirects you to the bot configuration page.
Select the API section on the bot configuration page.
Under the Tokens section, click the Generate Token button.
Save and store the generated token in a safe place.
Creating 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 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.
Obtaining 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, open the Creations page on Creator Dashboard. Then select an experience and copy the Universe ID and Start Place ID.
Adding 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:
Install Python libraries using the following commands:
Install Librariespip3 install discordpip3 install guilded.py==1.8.0pip3 install requestspip3 install urllib3==1.26.6Copy and save the following scripts corresponding to different parts of the bot logic in the same directory:
bot_config.pyDISCORD_BOT_TOKEN = ""GUILDED_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 deletedSTANDARD_DATA_STORE_ENTRIES = {# Start Place ID111111111: (# Universe ID222222222,[("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 deletedORDERED_DATA_STORE_ENTRIES = {111111111: (222222222,[("OrderedDataStore1", "Scope2", "Key4_{user_id}")])}data_stores_api.pyimport requestsimport bot_configfrom collections import defaultdict"""Calls Data Stores Open Cloud API to delete all entries for a user_id configured inSTANDARD_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:continueuniverse_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 inORDERED_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:continueuniverse_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, failuresmessage_parser.pyimport timeimport hmacimport hashlibimport reimport base64import bot_config"""Parses received message for Roblox signature and timestamp, the footer is only set if youconfigured webhook secret"""def parse_footer(message):if not message.embeds[0].footer or \not message.embeds[0].footer.text:return "", 0footer_match = re.match(r"Roblox-Signature: (.*), Timestamp: (.*)",message.embeds[0].footer.text)if not footer_match:return "", 0else: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 windowrequest_timestamp_ms = timestamp * 1000window_time_ms = 300 * 1000oldest_timestamp_allowed = round(time.time() * 1000) - window_time_msif request_timestamp_ms < oldest_timestamp_allowed:return False# Validates signaturetimestamp_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 signaturereturn True"""Parses a received webhook messaged on Discord or Guilded. Extracts user ID, prevents replay attackbased on timestamp received, and verifies Roblox signature with configured secret to check forvalidity."""def parse_message(message):# Parses received message for user ID and game IDif 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_idselse:return "", []guilded_bot.pyimport guildedimport jsonimport bot_configimport data_stores_apiimport message_parserdef run():client = guilded.Client()@client.eventasync def on_ready():print(f"{client.user} is listening to Right to Erasure messages")"""Handler for webhook messages from Roblox"""@client.eventasync def on_message(message):# Parses and validates messageuser_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.GUILDED_BOT_TOKEN)if __name__ == "__main__":run()On the bot_config.py file for main configuration of the bot:
- Set DISCORD_BOT_TOKEN or GUILDED_BOT_TOKEN to the token generated by your bot.
- Set OPEN_CLOUD_API_KEY as the API key you created.
- Set ROBLOX_WEBHOOK_SECRET as the secret you set when configuring the webhook on Creator Dashboard.
- In STANDARD_DATA_STORE_ENTRIES and ORDERED_DATA_STORE_ENTRIES dictionaries for locating the data store of each record to delete:
- Add your copied Start Place IDs as keys.
- Add Universe IDs as the first element of the tuple value.
- 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.
Execute the following command to run the bot:
Run Guilded Botpython3 guilded_bot.pyThe 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.
Testing
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:
Send an HTTP POST request to your Guilded or Discord webhook server with the following request body:
Example Requestcurl -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}"}}]}'If you have a webhook secret:
- Generate a Roblox-Signature by applying HMAC-SHA256 encoding to your webhook secret key.
- Set the current time using UTC timestamp in seconds as Timestamp.
Put together the description in the following format:
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:
Example Description Field1683927229. 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.
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"
}
}
]
}