Saved Sep 19, 2026 by @enviglo: Matches the fixes to codes, bundles, requests and bans
| `LICENSE_GRANTED` | License granted | A purchase was recorded or a license was granted. || `LICENSE_REVOKED` | License revoked | A license was revoked or restored. || `LICENSE_TRANSFERRED` | License transferred | A license moved to a new owner. |+ | `LICENSE_EXPIRED` | Subscription ended | A subscription ran out, so its owner no longer has access. || `PRODUCT_PUBLISHED` | Product published | A product went live or was unpublished. || `PRODUCT_UPDATED` | Product updated | Product details were saved. || `FILE_UPLOADED` | File uploaded | A new file version is available. |…Some events cover more than one thing, and the payload says which:- - `LICENSE_GRANTED` covers purchases recorded in game, including renewals and extra copies, as well as licenses your team grants and free products people claim. For an in-game purchase, `data.outcome` is `created`, `copy_added` or `renewed`.+ - `LICENSE_GRANTED` covers purchases recorded in game, including renewals and extra copies, as well as licenses your team grants, codes people redeem, free products people claim and the products inside a bundle. For an in-game purchase, `data.outcome` is `created`, `copy_added` or `renewed`.+ - `LICENSE_EXPIRED` is sent once, when the subscription's end date passes. Its `data` has the `license` (with its `expiresAt` and owner) and the `product`.- `LICENSE_REVOKED` covers both directions: `data.action` is `revoked` or `restored`.- For `PRODUCT_PUBLISHED`, check `data.product.status`.- For `REVIEW_POSTED`, `data.review.updated` is `true` when a customer changed a review they'd already posted.…A delivery counts as delivered when your server answers with a status in the 200s within 8 seconds. Enviglo doesn't follow redirects, so use your endpoint's final address.- When a delivery fails, Enviglo tries again up to five more times, waiting longer each time, from a few minutes at first to about six hours before the last try. After six tries in all, it gives up on that event.+ When a delivery fails, Enviglo tries again up to five more times, waiting longer each time: a minute before the first retry, then five minutes, half an hour, two hours and six hours. After six tries in all, it gives up on that event.- Each try is a fresh request, with its own `id`, timestamp and signature. If your server did the work but didn't answer in time, the same event can arrive twice, so make your handler safe to run twice, for example by remembering which licenses you've already handled.- After 25 failed tries in a row, across all events, Enviglo switches the webhook off. The card shows it as **Paused**, with "Disabled after repeated failures", and events waiting for a retry are given up. Fix your server, then press the play button to resume it.…
A webhook sends a request to your own server when something happens in your store, like a purchase or a new review, within seconds of it happening. This guide covers adding one, the events you can choose, what arrives, and how to check each request really came from Enviglo.
https address on the public internet that answers within 8 seconds with a status in the 200s.https://.This is the only time Enviglo shows the signing secret. It starts with whsec_, and you need it to check the signature.
Enviglo turns away addresses that aren't https, that point at localhost or a private network, or that have a username or password in them.
| Event | In the dashboard | Sent when |
|---|---|---|
LICENSE_GRANTED | License granted | A purchase was recorded or a license was granted. |
LICENSE_REVOKED | License revoked | A license was revoked or restored. |
LICENSE_TRANSFERRED | License transferred | A license moved to a new owner. |
LICENSE_EXPIRED | Subscription ended | A subscription ran out, so its owner no longer has access. |
PRODUCT_PUBLISHED | Product published | A product went live or was unpublished. |
PRODUCT_UPDATED| Product updated |
| Product details were saved. |
FILE_UPLOADED | File uploaded | A new file version is available. |
REVIEW_POSTED | Review posted | A customer posted or updated a review. |
Some events cover more than one thing, and the payload says which:
LICENSE_GRANTED covers purchases recorded in game, including renewals and extra copies, as well as licenses your team grants, codes people redeem, free products people claim and the products inside a bundle. For an in-game purchase, data.outcome is created, copy_added or renewed.LICENSE_EXPIRED is sent once, when the subscription's end date passes. Its data has the license (with its expiresAt and owner) and the product.LICENSE_REVOKED covers both directions: data.action is revoked or restored.PRODUCT_PUBLISHED, check data.product.status.REVIEW_POSTED, data.review.updated is true when a customer changed a review they'd already posted.The Test button sends a PING event, whatever you ticked.
Each delivery is a POST with a JSON body and these headers:
| Header | What it holds |
|---|---|
Content-Type | application/json |
User-Agent | Enviglo-Webhooks/1.0 |
X-Enviglo-Event | The event, like LICENSE_GRANTED. |
X-Enviglo-Delivery | An ID for this delivery, the same as the body's id. |
X-Enviglo-Timestamp | When it was sent, in Unix seconds. |
X-Enviglo-Signature | v1= followed by the signature. |
The body always has the same four fields: id, event, createdAt (when it was sent) and data. A license event looks like this:
{
"id": "7c1e2a4b-5d6f-4a8b-9c0d-1e2f3a4b5c6d",
"event": "LICENSE_GRANTED",
"createdAt": "2026-09-18T04:12:10.000Z",
"data": {
"license": {
"id": "clx8f2k0a0001",
"status": "ACTIVE",
"source": "ROBLOX_PURCHASE",
"quantity": 1,
"expiresAt": null,
"createdAt": "2026-09-18T04:12:09.000Z",
"updatedAt": "2026-09-18T04:12:09.000Z",
"owner": { "userId": "u_4kq8", "username": "aurora", "robloxId": "1002" }
},
"product": {
"id": "clx7a1b2c",
"slug": "advanced-admin",
"name": "Advanced Admin",
"robloxProductId": "1234567890",
"robloxGamePassId": null,
"discordRoleId": null
},
"via": "api",
"receiptId": "a1b2c3d4e5",
"outcome": "created"
}
}
The owner is an Enviglo account. owner.robloxId is the Roblox account linked to it, and null if there isn't one. Alongside license and product, a license event carries a few fields about what happened, depending on how it came about.
The other events carry:
product, with its id, slug, name, status, priceCents, robuxPrice and updatedAt.product (id and name), file, with its id, version, fileName, sizeBytes and createdAt, and uploadedBy, the username of whoever uploaded it.review (id, rating, body and updated), product (id, name and slug), and author (id and username).store (id, slug and name) and sentBy, the username of whoever pressed Test.Every request is signed with your webhook's signing secret, so you can turn away anything that didn't come from Enviglo:
X-Enviglo-Timestamp header and the raw request body, exactly as they arrived.., then the body.X-Enviglo-Signature after v1=, using a constant-time comparison.In Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as it arrived, as a string.
// headers: the request's headers, with lower-case names, as Node gives them.
export function verifyEnviglo(rawBody, headers, secret) {
const timestamp = String(headers["x-enviglo-timestamp"] ?? "");
const signature = String(headers["x-enviglo-signature"] ?? "");
if (!/^\d+$/.test(timestamp) || !signature.startsWith("v1=")) return false;
// Turn away anything more than five minutes old, so a copied request can't be replayed later.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const given = signature.slice("v1=".length);
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
The signature covers the exact bytes Enviglo sent, so check it before you parse the JSON, and never against a body you've parsed and turned back into text. A server that reads the raw body:
import { createServer } from "node:http";
import { verifyEnviglo } from "./verify-enviglo.js"; // the function above, in its own file
const SIGNING_SECRET = "YOUR_SIGNING_SECRET"; // whsec_…, kept wherever your server keeps secrets
createServer((req, res) => {
let rawBody = "";
req.setEncoding("utf8");
req.on("data", (chunk) => (rawBody += chunk));
req.on("end", () => {
if (!verifyEnviglo(rawBody, req.headers, SIGNING_SECRET)) {
res.writeHead(401).end();
return;
}
const delivery = JSON.parse(rawBody);
console.log(delivery.event, delivery.data);
res.writeHead(200).end();
});
}).listen(3000);
Answer quickly with a status in the 200s, and do any slow work afterwards: Enviglo waits 8 seconds at most.
A delivery counts as delivered when your server answers with a status in the 200s within 8 seconds. Enviglo doesn't follow redirects, so use your endpoint's final address.
When a delivery fails, Enviglo tries again up to five more times, waiting longer each time: a minute before the first retry, then five minutes, half an hour, two hours and six hours. After six tries in all, it gives up on that event.
id, timestamp and signature. If your server did the work but didn't answer in time, the same event can arrive twice, so make your handler safe to run twice, for example by remembering which licenses you've already handled.Press Test next to a webhook to send it a PING event, signed like any other. Enviglo tells you what came back: "Ping delivered (HTTP 200).", the status your server answered with, or why it couldn't reach it. A failed ping isn't retried: press Test again once you've fixed things.
A store can send 10 test pings every 10 minutes.
Adding, pausing, resuming and deleting webhooks shows in your store's Audit log.
| Plan | Webhooks |
|---|---|
| Free | 1 |
| Pro | 5 |
| Ultimate | 15 |
Paused webhooks count too. At the limit, Enviglo says so, for example "Your plan covers 1 webhook. Pro raises that to 5." See Plans, limits and fees.
https:// address.X-Enviglo-Timestamp header, and the whole signing secret, including whsec_. Compare against the signature without its v1=.