← Back to writing
Technical Memo | Roblox Engineering

How I Receive and Process Roblox Commerce Refund Events

A walkthrough of the pipeline I built to receive Roblox commerce refund webhooks, verify and deduplicate them off-platform, queue them safely, and deliver exact in-game clawbacks keyed to the original purchase receipt.


The Shape of the Problem

Roblox commerce refunds are delivered outside the game, so the engineering problem is not deciding where refund processing should live. That part is dictated by the platform. The real work is building a pipeline that can receive those refund events, prove they are authentic, ignore duplicates, survive retries, and turn them into safe in-game clawbacks.

The failure mode I wanted to avoid was simple: a player buys a commerce product, gets the reward, later receives a refund or chargeback, and then the game either removes the wrong item or fails to remove anything at all. Once players can own multiple copies of the same reward, deleting by item type is not good enough. The system needs to target the exact grant associated with the refunded purchase.

What the Webhook Needs to Produce

For the game to claw back a refunded reward safely, the webhook processing layer has to extract three identifiers from the incoming event.

  1. Order ID to make the endpoint idempotent. If Roblox retries the webhook, I should process it once.
  2. Commerce product ID to map the refund event back to the in-game product definition.
  3. Purchase receipt ID to identify the exact granted item instance that needs to be removed later.

In practice, the payload fields I care about are the order path, the order state, the user path, and the original purchase receipt embedded in the grants list. The event becomes actionable only after those fields are parsed into a stable clawback record.

Receiving the Endpoint Correctly

The first important implementation detail is that the route cannot start with normal JSON parsing. Signature verification depends on the raw request body, so the endpoint has to receive the payload as bytes first, not as a parsed object. Only after the signature is verified should the body be decoded as JSON.

The handler reads the roblox-signature header, extracts the timestamp and signature components, rejects stale requests, and computes an HMAC over timestamp + "." + rawBody. If the calculated signature does not match, the request is rejected immediately. This protects the endpoint from forged refund events and accidental malformed traffic.

Why this matters

Refund webhooks are not analytics events. They are state-changing inputs. Once processed, they can remove paid items from a player. That means authenticity checks are not optional hardening. They are part of correctness.

Interpreting the Event

After verification, the webhook is parsed and normalized into the identifiers the rest of the system needs. The order path yields the order ID and the commerce product ID. The user field yields the Roblox user ID. The grants array provides the original receipt ID, which becomes the key to exact-match revocation inside the game.

I only take action when the order enters the refunded state. Everything else is acknowledged but ignored. The end result is a small clawback object containing a CommerceID and a PurchaseID, associated with a specific player and order.

Idempotency Is Mandatory

Webhook systems retry. That is normal, not exceptional. Because of that, the refund endpoint has to be idempotent. My implementation handles this with an atomic insert into MongoDB using a unique index on order ID. If the insert succeeds, this is the first time I have seen the event. If it fails with a duplicate-key error, the webhook has already been processed and the endpoint returns success without doing the work again.

This is the critical distinction between "I received the request twice" and "I performed the clawback twice." The endpoint must allow the first while preventing the second.

Why the Refund Is Queued

Once the webhook has been validated and deduplicated, the next instinct might be to write the clawback directly into Roblox DataStore immediately. That works until the same user has multiple refund events in flight at once. At that point, concurrent workers can fight over the same player key and generate version conflicts.

To avoid that, I enqueue refund work in MongoDB and process it per user. The queue groups pending entries by player, claims a batch, and then writes those clawbacks in one operation. The queue processor also recovers timed-out work and retries failures. The important part is not the specific polling interval. It is the ownership model: one user, one batch, one DataStore write path at a time.

The DataStore Inbox

The handoff from the external service into the game happens through a dedicated DataStore entry per player. I treat that entry as a clawback inbox. When new refunds arrive, the processor reads the current list, appends the new clawback records, and writes the updated list back using version matching. That gives the game a durable source of pending work even when the player is offline.

After the inbox is updated, the API sends a throttled message to live servers telling them a player has pending commerce clawbacks. If the player is already online, the server can claim and process the inbox quickly. If the player is offline, nothing is lost. The work waits in DataStore until they join.

How the Game Applies the Clawback

Inside the game, the clawback processor tries the online path first. It loads the player inventory, maps the CommerceID back to a commerce product definition, finds the corresponding item displays for that product, and searches the inventory for candidate items of those types.

The important part is the final filter: candidate items are only removed if their stored purchase data contains the same purchase receipt ID as the refunded order. In other words, the system does not remove "one copy of this pet." It removes the exact copy granted by that purchase. If the player owns two identical rewards from two separate purchases, only the refunded one matches.

This is the key design choice

Revocation is safe only because granted items carry immutable purchase metadata and clawbacks are keyed to that metadata. Without that, the system would have to guess, and guessing is how you delete the wrong item.

Why Purchase Metadata Has to Be Stamped at Grant Time

None of this works unless the granted item records the original purchase receipt when it is created. That metadata is what connects a later refund event to a specific inventory object. In my implementation, granted items carry a purchase data payload containing the purchase ID, purchasing user, product, place, and timestamp. The external webhook does not remove items by itself. It only creates a clawback instruction. The final in-game match happens against the stored purchase record on the item.

Offline Players and Deferred Claims

If the player cannot be processed immediately, the clawback remains in the inbox. On join, the game atomically claims pending entries by reading and clearing the DataStore list, then attempts removal again once the player is fully loaded. That gives the refund pipeline eventual completion without requiring the player to be present at the moment the webhook arrives.

This is one of the reasons I prefer thinking about the DataStore entry as an inbox rather than just a cache of refund state. It is durable work waiting to be consumed by the game under the right conditions.

Operational Lessons

The main lesson from building this was that refund handling is a distributed systems problem wearing a commerce label. The correctness properties come from a few simple rules applied consistently.

  1. Verify signed webhook requests against the raw body before parsing JSON.
  2. Treat webhook retries as normal and make the endpoint idempotent on order ID.
  3. Queue work by user to avoid fighting over the same Roblox DataStore key.
  4. Write pending clawbacks into a durable in-game inbox rather than depending on the player being online.
  5. Stamp granted items with immutable purchase metadata and revoke by purchase receipt, not by item type.

What the System Actually Guarantees

The pipeline does not guarantee that every refund is resolved instantly. That is not the goal. It guarantees that authentic refund events are processed once, turned into durable clawback work, and eventually applied by the game without deleting unrelated items. In practice, that is the difference between a refund system that merely exists and one that can be trusted.


← Back to writing