Gifting
Ava wants to buy Ben a gem pack. Roblox has no “gift this to someone else” API, so gifting is really a sale to Ava whose effects land on Ben’s profile, and the hard part is making sure that still works when Ben is offline, on another server, or joins next week. Scribe records where a gift is aimed before any Robux moves, and delivers it whenever the recipient turns up.
You need Monetization first. Gifting reuses the same Products you already declared.
Sending a gift
Section titled “Sending a gift”Any product can be gifted. In Emberfall that is GemPack100, which is already in the shared module.
local ok, reason = Data.PromptGift(ava, "GemPack100", benUserId)if not ok then showToast(ava, reason)endPromptGift yields while it writes a durable record of the intent, then shows Ava the Robux prompt. It returns (boolean, string?), and you must handle the false branch: refusal here is routine, not exceptional. The default gift cooldown alone is five seconds per sender, so any button a player can double-click will produce one.
Ben receives the product’s effects exactly as if he had bought it himself. GemPack100 has a Grant, so his Gems go up by 100 the moment delivery lands, wherever he is.
One unresolved gift per product
Section titled “One unresolved gift per product”Until a paid gift settles or its current prompt is confirmed cancelled, Scribe refuses another paid gift of the same product. PromptPurchase also refuses that product while its gift is unresolved. A later recipient cannot replace the first one just because time passed or the buyer rejoined.
Purchase and gift prompts share one open-dialog guard per buyer. A matching cancellation clears only that prompt’s gift record, provided no receipt has already claimed it. Scribe saves that removal before releasing the guard. Failed prompts and attempts aborted before opening use the same cleanup.
If saving the removal fails, new Scribe prompts stay blocked. The next PromptGift or PromptPurchase attempt retries the save automatically. A purchase-completed prompt event only closes the dialog; ProcessReceipt handles all paid delivery.
If the buyer leaves before cancellation is recorded, or the server crashes before saving it, the saved gift may remain blocked. Waiting longer does not prove that no money moved. Check the gift and receipt records before an operator repairs it; Scribe does not discard unresolved destinations on a timer. Spending an existing gift credit does not create another paid receipt.
Older profiles can contain conflicting pending and archived gifts for one product. Scribe holds an unbound receipt in that case for repair; an exact destination already saved for its purchase ID still takes precedence.
A stored intent or aim with an invalid recipient ID also holds the receipt for repair. Scribe logs GIFT_INVALID_RECIPIENT before saving a new receipt destination or attempting delivery. Check the original gift records; do not delete them to force the receipt through.
What true does not tell you
A true return means one of two things: a Robux prompt was shown and nothing has been delivered yet, or a held gift credit was consumed and the gift went out immediately at no charge.
It never means “Ben has his gems”. Ava may close the prompt, and even a completed purchase is delivered asynchronously. To know a gift actually landed, listen for the signal below.
Reacting to a delivery
Section titled “Reacting to a delivery”Delivery can happen on another server, or out of the offline mailbox on Ben’s next join, so a signal is the only correct way to react to one.
-- server, on the RECIPIENTData.OnGiftReceived:Connect(function(player, info) -- info = { FromUserId, Product, GiftId } announce(player, `A gift from {info.FromUserId}!`)end)OnGiftReceived includes the sender and gift ID, so use it for the “Ben, Ava sent you this” moment. For a plainer question like “does Ben own VIP now”, use OnOwnershipChanged or ObserveOwned instead. Those already cover gift deliveries alongside ordinary purchases, whatever route the value took.
Gift credits
Section titled “Gift credits”Sometimes Ava’s money arrives and there is nobody sensible to give it to. Rather than burn the Robux, Scribe converts the purchase into a gift credit: a durable, re-aimable token the buyer keeps. A later PromptGift consumes it and charges nothing.
-- server, on the BUYERData.OnGiftCredit:Connect(function(player, productName) showToast(player, `Your {productName} gift is unclaimed. Pick someone else.`)end)Read the balance with Data.GetGiftCredits(player) on the server or Data.GetGiftCredits() on the client. Both return a { [productName]: count } map, and credits replicate to their owner by default, so a client gift UI needs no opt-in.
-- clientfor productName, count in Data.GetGiftCredits() do print(`{count} unclaimed {productName}`)endWhen a credit is issued instead of a delivery
Credits only arise for a perk product, meaning one declared with Grants and no Grant. A product with a Grant callback delivers real value to anyone, so there is no such thing as a wasted delivery.
Two situations produce one. The recipient acquired the perk between the prompt and the receipt, so delivering it would be a no-op: Scribe writes a credit and logs GIFT_RECIPIENT_ALREADY_OWNS. Or a perk product was bought with no gift intent at all by a buyer who already owns it, which is GIFT_CREDIT_ISSUED.
The second case is governed by NoGiftIntentPolicy. The default, "GrantOrCredit", writes the credit. "Hold" keeps the receipt pending for retry or manual resolution; it does not issue a refund. If the buyer already holds an unused credit for that product, the receipt stays pending rather than stacking a second one.
Reading the refusal
Section titled “Reading the refusal”Ownership is checked twice, at prompt time and again at receipt time, so a race between two gifters cannot double-deliver. Refusals are exported as a frozen table. All but one are text the buyer can be shown; InvalidRecipient means the call was handed an invalid user ID, which is the developer’s to fix:
Scribe.GiftReason member | The string | For |
|---|---|---|
BuyerDataNotLoaded | "buyer data not loaded" | the player, as “try again” |
InvalidRecipient | "invalid recipient" | the developer |
CannotGiftYourself | "cannot gift yourself" | the player |
GiftCooldown | "gift cooldown" | the player |
ProductRetired | "product retired" | the player; existing paid credits remain redeemable |
TooManyPending | "too many pending gifts" | the player |
DataServicesDown | "data services are experiencing issues; try again later" | the player |
RecipientAlreadyOwns | "recipient already owns this" | the player |
CreditReserveFailed | "could not reserve gift credit; try again later" | the player |
DeliveryFailed | "could not deliver gift; try again later" | the player |
DeliveryUnconfirmed | "gift delivery could not be confirmed; do not send it again" | the player |
AlreadyPending | "a gift of this item is already pending; try again shortly" | the player; an unresolved gift or another open purchase dialog blocks this prompt |
IntentRecordFailed | "could not record gift intent; try again later" | the player |
PaidRandomRestricted | "paid random items are not available for this account" | the player |
PolicyPending | "cannot check account settings right now; try again in a moment" | the player |
The last two belong to a product declared PaidRandom = true: the buyer is restricted, or the buyer’s or recipient’s policy is not known, which for a recipient who is not on this server it never is. They are sentences, like the rest of this table; Data.PromptPurchase answers those same two conditions with the shorter Scribe.ProductState codes, because a shop branches on those rather than showing them. Paid random items has the rule.
local ok, reason = Data.PromptGift(ava, "GemPack100", benUserId)if not ok then if reason == Scribe.GiftReason.GiftCooldown then return -- a double-click, say nothing elseif reason == Scribe.GiftReason.RecipientAlreadyOwns then showToast(ava, "Ben already has that.") else showToast(ava, reason) endendWhy one of them tells the player NOT to retry
DeliveryFailed and DeliveryUnconfirmed are both delivery refusals and they are opposites.
A gift to someone offline or on another server is delivered by committing a message to their profile key. That write can commit and then lose its answer — a timeout on the way back, or the retry loop ending at shutdown — and Scribe cannot tell that apart from a write that never happened. So it splits the two outcomes. When the delivery provably did not go out you get DeliveryFailed, the spent gift credit is handed straight back, and retrying is exactly right.
A full inbox is not by itself proof. The store retries internally, so a refusal can be reported for a message an earlier attempt of the same call already queued. Scribe asks the store whether any attempt could have written rather than assuming the refusal speaks for the call. Confirmed failures include a same-server grant rolled back after an error, a recipient whose receipt history cannot admit the gift, and an inbox refusal where no attempt reached storage. Only a confirmed failure restores the spent credit.
When it is unconfirmed you get DeliveryUnconfirmed, and the credit stays spent. That is deliberate. A gift credit carries no id a retry could reuse — each spend mints a fresh one — so handing the credit back for a gift that was in fact queued lets the same payment deliver a second time, which the recipient’s duplicate check cannot catch. Scribe would rather cost one credit than grant twice. It logs GIFT_CREDIT_UNCONFIRMED with the buyer, the product and the recipient so you can reconcile the rare case by hand, and counts GiftCreditsUnconfirmed.
Do not fold the two into one “try again” toast. The retry is the part that double-grants.
Three more refusals exist that cannot be members of the table. Two carry an interpolated value: unknown product "<name>" and purchase prompt failed: <err>. The third, "too many unsettled gift purchases; try again later", means the buyer’s durable aim store is full of gifts whose receipts have not settled yet.
Why so many of them are about writing something down
Four refusals (DataServicesDown, IntentRecordFailed, CreditReserveFailed, and the full aim store) all say the same thing in different words: Scribe could not durably record where this gift was going, so it refused to let money move.
The intent is saved before the purchase prompt appears, because Roblox’s receipt does not name the gift recipient. When the paid receipt arrives, Scribe saves its exact recipient under that receipt’s ID before delivery. This binding survives expired intents, later gifts of the same product, and a crash between recipient delivery and buyer acknowledgement.
A receipt Scribe has never handled still relies on its intent or an archived gift aim. Unresolved aims do not expire. Their store is bounded to 64 slots per buyer, including reserved space for pending intents; a full store refuses new paid gifts. If an older profile has no room to archive an intent, Scribe keeps the intent instead of losing its destination.
External purchases and pending gifts
Section titled “External purchases and pending gifts”A purchase outside the experience must not take the recipient from a different gift. For example, if Ava has a pending gem-pack gift for Ben and buys another pack from the game’s Roblox page, that external purchase follows the normal buyer purchase path. Ben’s pending gift stays intact.
Scribe uses the receipt’s ProductPurchaseChannel: only InExperience receipts can claim a pending gift intent or archived aim by product. Receipts without a channel keep the previous behavior for compatibility. A destination already saved for the exact PurchaseId takes precedence regardless of channel, so a retry still settles its original gift.
Keep products that require a gift-recipient selection off external listings. See external purchases for setup and restrictions.
Throttles worth knowing
Section titled “Throttles worth knowing”Gifting is a spam and abuse surface, so it ships throttled. All of these live under Configuration.
| Option | Default | What it bounds |
|---|---|---|
GiftCooldown | 5 seconds | Minimum gap between one sender’s prompts. |
GiftMaxPending | 20 | Unresolved intents one sender may hold at once. |
GiftIntentTTL | 3600 seconds | Age after which loading a profile moves an intent into the aim store. It does not cancel the gift or release its product slot. |
AllowDuplicateGifts | false | Whether a perk the recipient already owns may be gifted anyway. |
NoGiftIntentPolicy | "GrantOrCredit" | What an intentless perk purchase becomes. |
A second paid gift of the same product by the same sender is refused with AlreadyPending while either its intent or archived aim remains unresolved. Neither the old two-minute prompt window nor the intent’s age permits replacement. Once a paid receipt is bound to a recipient, retries keep that exact destination.
Why gifting grants a perk rather than a pass
There is no API to transfer a game pass, so a “gift VIP” product is a developer product whose Grants names a perk. Scribe treats perks and passes as one ownership namespace, so Owns(ben, "VIP") is true either way and the rest of your game needs no special case. See Checking what a player owns.
The consequence to plan for is that a renamed or deleted product strands any gift credits keyed to its old name. Scribe warns at load with GIFT_CREDIT_UNKNOWN_PRODUCT and never deletes them, because a credit is money the player already paid. Restore the product entry or rename the key in a migration.
Where to next
Section titled “Where to next”- Monetization for products, passes, perks and soft-currency purchases.
- Configuration for every gifting option and its default.
- Log Code Reference for gift log codes and what each one means.
- Diagnostics for the
GiftPromptsandGiftsDeliveredcounters.