Saved Sep 19, 2026 by @enviglo: First version
+ 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.+ ## Before you start++ - You need the **Integrations** permission. Owners and admins have it; staff need it given to them. See [Roles and permissions](/wiki/roles-and-permissions).+ - You need an `https` address on the public internet that answers within 8 seconds with a status in the 200s.++ ## Add a webhook++ 1. In your store's dashboard, open **Settings** → **Integrations**.+ 2. In the **Webhooks** card, enter your **Endpoint URL**, starting with `https://`.+ 3. Under **Events**, tick what you want to hear about. The three license events are ticked to start with.+ 4. Press **Add webhook**.+ 5. Your signing secret appears in a box at the top of the card. Press **Copy**, store it somewhere safe on your server, then press **Done**.++ This is the only time Enviglo shows the signing secret. It starts with `whsec_`, and you need it to [check the signature](#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.++ ## Events++ | 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. |+ | `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 and free products people claim. For an in-game purchase, `data.outcome` is `created`, `copy_added` or `renewed`.+ - `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.++ The **Test** button sends a `PING` event, whatever you ticked.++ ## What Enviglo sends++ 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:++ ```json+ {+ "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 published** and **Product updated:** `product`, with its `id`, `slug`, `name`, `status`, `priceCents`, `robuxPrice` and `updatedAt`.+ - **File uploaded:** `product` (`id` and `name`), `file`, with its `id`, `version`, `fileName`, `sizeBytes` and `createdAt`, and `uploadedBy`, the username of whoever uploaded it.+ - **Review posted:** `review` (`id`, `rating`, `body` and `updated`), `product` (`id`, `name` and `slug`), and `author` (`id` and `username`).+ - **Ping:** `store` (`id`, `slug` and `name`) and `sentBy`, the username of whoever pressed **Test**.++ ## Check the signature++ Every request is signed with your webhook's signing secret, so you can turn away anything that didn't come from Enviglo:++ 1. Take the `X-Enviglo-Timestamp` header and the raw request body, exactly as they arrived.+ 2. Join them with a full stop: the timestamp, then `.`, then the body.+ 3. Work out the HMAC-SHA256 of that with your signing secret, as lower-case hex.+ 4. Compare it with the part of `X-Enviglo-Signature` after `v1=`, using a constant-time comparison.+ 5. Turn away old requests: check the timestamp is within a few minutes of now.++ In Node.js:++ ```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:++ ```js+ 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.++ ## Retries and failures++ 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.++ - 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.+ - **Recent deliveries**, under each webhook, lists its last eight events: delivered, retrying or given up, with the error when there was one. Press **Retry** on one to send it again now. A paused webhook has to be resumed first.++ ## Test it++ 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.++ ## Pause, change or delete++ - **Pause:** press the pause button next to a webhook. It gets no events until you press the play button to resume it.+ - **Change:** you can't edit a webhook's address or events. Add a new webhook with what you want, then delete the old one. The new one has its own signing secret.+ - **Delete:** press the bin button, then **Delete**. It stops receiving events immediately.++ Adding, pausing, resuming and deleting webhooks shows in your store's **Audit log**.++ ## Plan limits++ | 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](/wiki/plans-and-fees).++ ## Troubleshooting++ - **"Webhook URLs must use https."** Use an `https://` address.+ - **"Webhooks cannot point at localhost." or "Webhooks cannot point at private network addresses."** Enviglo only sends to addresses on the public internet.+ - **"Remove credentials from the URL."** Take the username and password out of the address.+ - **"Pick at least one event."** Tick at least one event.+ - **"Too many test pings in a short time."** Wait for the time the message gives, then test again.+ - **"This endpoint is paused. Resume it before retrying."** Press the play button, then **Retry**.+ - **Signatures never match.** Check you're using the raw body, the `X-Enviglo-Timestamp` header, and the whole signing secret, including `whsec_`. Compare against the signature without its `v1=`.
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. |
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 and free products people claim. For an in-game purchase, data.outcome is created, copy_added or renewed.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, 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.
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=.