Webhooks let external systems receive real-time notifications about events that happen inside Gridlock and LemonSqueezy billing. Instead of polling the API on an interval, you register an HTTPS endpoint and Gridlock sends an HTTP POST to that URL whenever a relevant event occurs.
Gridlock processes two categories of webhook traffic. The first is billing webhooks from LemonSqueezy, which drive subscription lifecycle automation. The second is the outbound webhook system where Gridlock can notify your endpoints about security and agent events on your account. This guide covers both, with particular depth on the LemonSqueezy integration since it is active in production today.
LemonSqueezy webhooks drive Gridlock's subscription management in production. Events from
LemonSqueezy trigger account provisioning, plan upgrades, downgrades, and cancellations automatically.
The webhook handler at /api/billing/webhook is the system of record for all subscription
state changes.
A webhook is an HTTP callback. When an event occurs in Gridlock or LemonSqueezy, the system sends
a POST request to a URL you have configured, with a JSON body describing the event. Your endpoint
receives this request, processes it, and responds with 200 OK to acknowledge receipt.
Webhooks are used to:
The following events are currently emitted by LemonSqueezy and processed by Gridlock's billing webhook handler. Each event triggers specific business logic inside the platform.
| Event Type | Triggered When | Gridlock Action |
|---|---|---|
| subscription_created | A customer completes checkout and starts a new subscription on any plan. | Creates or upgrades the account record, sets the plan tier (Starter / Pro / Ultimate), activates the account if new. |
| subscription_updated | A subscription's plan, billing interval, or quantity changes. | Updates the account's plan tier and feature flags. Sends the customer a confirmation email via the account manager. |
| subscription_cancelled | A subscription is cancelled, either by the customer or by LemonSqueezy due to payment failure. | Marks the account for grace period handling. Disables access at period end. Triggers churn risk alert in the Account Manager agent. |
| order_created | A one-time order is completed (add-on purchase, manual invoice payment). | Logs the order, credits any associated add-on entitlements to the account, triggers receipt generation. |
The subscription_cancelled event is the most consequential billing webhook. Gridlock
initiates a grace period on receipt and begins the access-revocation countdown. If your webhook
handler is down and this event is missed, the account may retain access beyond the cancelled period.
Ensure your handler returns 200 OK quickly and processes the event asynchronously if
needed — do not block the response on slow database operations.
Every webhook is delivered as an HTTP POST with Content-Type: application/json. The
request body is a JSON object. The top-level structure is consistent across all events; the
data object varies by event type.
| Field | Type | Description |
|---|---|---|
meta.event_name |
string | The event type identifier, e.g. subscription_created. |
meta.webhook_id |
string | Unique ID for this specific delivery attempt. Use for idempotency deduplication. |
meta.test_mode |
boolean | true for test-mode events from LemonSqueezy sandbox. false for live events. |
data.id |
string | LemonSqueezy resource ID for the primary object (subscription ID, order ID, etc.). |
data.type |
string | LemonSqueezy resource type, e.g. subscriptions, orders. |
data.attributes |
object | The full resource attributes for this event. Contents vary by event type. |
{
"meta": {
"event_name": "subscription_created",
"webhook_id": "wh_01hxk3v9m2jqrp7f4c8dn5yt6w",
"test_mode": false,
"custom_data": {
"user_id": "usr_a1b2c3d4"
}
},
"data": {
"id": "sub_78234",
"type": "subscriptions",
"attributes": {
"store_id": 12345,
"order_id": 98765,
"customer_id": 55441,
"product_name": "Gridlock Pro",
"variant_name": "Monthly",
"status": "active",
"status_formatted": "Active",
"card_brand": "visa",
"card_last_four": "4242",
"pause": null,
"cancelled": false,
"trial_ends_at": null,
"billing_anchor": 1,
"first_subscription_item": {
"id": 112233,
"price_id": 445566,
"quantity": 1
},
"urls": {
"update_payment_method": "https://checkout.lemonsqueezy.com/..."
},
"renews_at": "2026-07-01T00:00:00.000000Z",
"ends_at": null,
"created_at": "2026-06-01T14:30:00.000000Z",
"updated_at": "2026-06-01T14:30:00.000000Z"
}
}
}
{
"meta": {
"event_name": "subscription_cancelled",
"webhook_id": "wh_01hxk9z2p3nqrt5m7d1bv8yu4x",
"test_mode": false
},
"data": {
"id": "sub_78234",
"type": "subscriptions",
"attributes": {
"status": "cancelled",
"status_formatted": "Cancelled",
"cancelled": true,
"ends_at": "2026-06-30T23:59:59.000000Z",
"updated_at": "2026-06-01T16:45:00.000000Z"
}
}
}
Any HTTP endpoint that accepts POST requests from the internet is a potential attack surface. Without
verification, a malicious actor could send a fake subscription_created event and
potentially provision access. Gridlock signs every webhook delivery using HMAC-SHA256 so your
handler can verify that the request genuinely originated from LemonSqueezy.
Every webhook request includes an X-Signature header containing the HMAC-SHA256
hexadecimal digest of the raw request body, computed using your LemonSqueezy webhook signing secret
as the key.
X-Signature: 3d9b8f2a1e4c7d6f0b5a9e3c8f1d4a7b2e5c9f0d3a6b8c1e4f7d2a5b8c3e6f9
Never trust a webhook payload without verifying the signature first. Skipping verification means any party who knows your webhook URL can send arbitrary billing events to your handler, potentially manipulating subscription state. Verification must happen on the raw request body bytes — before any JSON parsing. Parsing first and then re-serializing changes the byte sequence and will cause legitimate signatures to fail.
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
// IMPORTANT: Use express.raw() — NOT express.json() — to get raw bytes for HMAC
router.post('/api/billing/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const signingSecret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET;
const receivedSig = req.headers['x-signature'];
if (!receivedSig) {
return res.status(400).json({ error: 'Missing X-Signature header' });
}
// Compute expected signature from raw body bytes
const expectedSig = crypto
.createHmac('sha256', signingSecret)
.update(req.body) // req.body is a Buffer when using express.raw()
.digest('hex');
// Constant-time comparison to prevent timing attacks
const sigBuffer = Buffer.from(receivedSig, 'hex');
const expectedBuffer = Buffer.from(expectedSig, 'hex');
if (sigBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(sigBuffer, expectedBuffer)) {
return res.status(401).json({ error: 'Invalid webhook signature' });
}
// Signature verified — safe to parse and process
const payload = JSON.parse(req.body.toString('utf8'));
const eventName = payload?.meta?.event_name;
// Acknowledge receipt immediately, process asynchronously
res.status(200).json({ received: true });
// Process event after responding
handleWebhookEvent(eventName, payload).catch(console.error);
}
);
async function handleWebhookEvent(eventName, payload) {
switch (eventName) {
case 'subscription_created':
await provisionSubscription(payload.data.attributes);
break;
case 'subscription_updated':
await updateSubscription(payload.data.id, payload.data.attributes);
break;
case 'subscription_cancelled':
await cancelSubscription(payload.data.id, payload.data.attributes);
break;
case 'order_created':
await recordOrder(payload.data.attributes);
break;
default:
console.log(`Unhandled webhook event: ${eventName}`);
}
}
Direct string comparison with === is vulnerable to timing attacks: an attacker can
measure how long comparison takes to deduce the correct signature one character at a time.
crypto.timingSafeEqual() runs in constant time regardless of where strings differ,
closing this attack vector.
Gridlock's billing webhook handler is a fixed internal route. The URL you configure in LemonSqueezy is your publicly accessible server that LemonSqueezy will POST events to.
https://yourdomain.com/api/billing/webhook.LEMONSQUEEZY_WEBHOOK_SECRET environment variable on your server. This is the shared
secret used for HMAC-SHA256 signature verification.LemonSqueezy cannot send webhooks to localhost. During local development, use a
tunneling tool to expose your local server. See the
Local Testing section below for a step-by-step
guide using ngrok or Cloudflare Tunnel.
LemonSqueezy considers a webhook delivery successful when your endpoint responds with any
2xx HTTP status code within 30 seconds. If your endpoint is unreachable, times out,
or returns a non-2xx status, LemonSqueezy retries the delivery using exponential backoff.
After 5 failed retries, LemonSqueezy marks the webhook as failed and sends a failure notification email to your account's registered address. The webhook configuration is not automatically disabled, but the specific event delivery is abandoned. You can view and replay failed deliveries from the LemonSqueezy Webhooks dashboard.
Your endpoint must return 200 OK within 30 seconds or LemonSqueezy will treat the
delivery as failed and schedule a retry. For events that trigger database writes, email sends, or
other potentially slow operations: return 200 immediately upon successful signature
verification, then process the event asynchronously in a background job or queue. The code example
in the Signature Verification section above demonstrates this pattern.
Because LemonSqueezy retries failed deliveries, your webhook handler must be idempotent — processing the same event twice should produce the same outcome as processing it once, without creating duplicate records or triggering duplicate actions.
The safest deduplication strategy uses the meta.webhook_id field present in every
payload. Store processed webhook IDs and skip any event whose ID you have already processed.
const processedWebhookIds = new Set(); // use a database in production
async function handleWebhookEvent(eventName, payload) {
const webhookId = payload?.meta?.webhook_id;
// Deduplicate: check if we have already processed this delivery
if (webhookId && processedWebhookIds.has(webhookId)) {
console.log(`Skipping duplicate webhook: ${webhookId}`);
return;
}
// Process the event
switch (eventName) {
case 'subscription_created':
await provisionSubscription(payload.data.attributes);
break;
// ... other cases
}
// Record as processed only after successful handling
if (webhookId) {
processedWebhookIds.add(webhookId);
// In production: INSERT INTO processed_webhooks (id, processed_at) VALUES (?, NOW())
}
}
An in-memory Set is only suitable for demonstration. In production, persist processed webhook IDs
to your database with a unique constraint on the webhook_id column. Insert the ID as
part of the same transaction that processes the event — this ensures atomicity and prevents both
duplicate processing and missed events under failure conditions.
LemonSqueezy cannot POST to localhost. To test your webhook handler during local
development you need to expose your local server via a public HTTPS URL. The two most common tools
for this are ngrok and Cloudflare Tunnel.
# 1. Install ngrok from https://ngrok.com/download or via package manager
brew install ngrok # macOS
choco install ngrok # Windows
# 2. Start your Gridlock server locally
npm start
# 3. In a separate terminal, expose port 3000 (or whatever port you use)
ngrok http 3000
# ngrok will output a URL like:
# Forwarding https://a1b2c3d4.ngrok.io -> http://localhost:3000
# 4. Set this as your LemonSqueezy webhook URL:
# https://a1b2c3d4.ngrok.io/api/billing/webhook
# 1. Install cloudflared
brew install cloudflared # macOS
# or download from https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup
# 2. Start a quick tunnel (no account required)
cloudflared tunnel --url http://localhost:3000
# Cloudflare will output a URL like:
# https://random-words-here.trycloudflare.com
# 3. Use this URL in LemonSqueezy webhook settings
meta.test_mode: true in the payload.LemonSqueezy Test Mode uses completely separate API keys and webhooks from Live Mode. A test
webhook secret will not verify live event signatures and vice versa. Maintain separate
LEMONSQUEEZY_WEBHOOK_SECRET values for test and production environments and ensure
your deployment pipeline injects the correct value for each environment.
A robust webhook handler handles errors gracefully without exposing internal details and without silently swallowing failures that require human attention.
| Situation | HTTP Response | Why |
|---|---|---|
| Signature verification failed | 401 Unauthorized |
Signals to LemonSqueezy that this is a security rejection, not a transient failure. LemonSqueezy will not retry 4xx responses. |
| Required fields missing from payload | 400 Bad Request |
Malformed payload — not a transient failure. No point retrying. |
| Unrecognized event type | 200 OK |
Acknowledge receipt even for events you don't handle. Unknown events are not errors — LemonSqueezy may add new event types. |
| Database write failed | 500 Internal Server Error |
Transient failure — signals LemonSqueezy to retry. Do not return 500 for logic errors, only for infrastructure failures. |
| Successfully processed | 200 OK |
Standard acknowledgment. Any 2xx code works; 200 is the conventional choice. |
If your handler receives a valid webhook for an already-cancelled subscription and throws a
business logic error, returning 500 will cause LemonSqueezy to retry the event
repeatedly. Return 200 for any event you have successfully received and understood,
even if you chose not to act on it. Reserve 500 exclusively for genuine infrastructure
failures (database down, queue unavailable) where a retry would succeed once the issue is resolved.
async function handleWebhookEvent(eventName, payload) {
const webhookId = payload?.meta?.webhook_id;
try {
// ... process event ...
console.log(JSON.stringify({
level: 'info',
message: 'Webhook processed',
webhook_id: webhookId,
event: eventName,
timestamp: new Date().toISOString()
}));
} catch (err) {
// Log with enough context to investigate, but don't include
// raw payment data or PII in your error tracking
console.error(JSON.stringify({
level: 'error',
message: 'Webhook processing failed',
webhook_id: webhookId,
event: eventName,
error: err.message,
timestamp: new Date().toISOString()
}));
// Re-throw so the calling handler can return 500
throw err;
}
}
Base URL, request and response format, status codes, rate limits, and versioning fundamentals for the Gridlock REST API.
JWT tokens and API keys in detail: how to obtain them, use them, rotate them, and secure them against common attack vectors.
Complete endpoint reference for every Gridlock API resource, with request schemas, response schemas, and example calls.