Getting Started
Quick Start Architecture
AI Agents
MSP Hunter Threat Researcher Tech Support Compliance Agent Account Manager Onboarding Agent
Compliance
HIPAA SOC 2 PCI-DSS NIST
Guides
First 30 Days Scaling
API
Overview Authentication Webhooks Endpoints

Webhooks

🔔 Webhooks 12 min read Updated June 2026

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.

Production Billing Webhooks Are Live

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.

What Gridlock Webhooks Are

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:

Webhook Events

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.
Always Process subscription_cancelled Promptly

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.

Webhook Payload Structure

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.

Common Envelope Fields

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.

Example: subscription_created Payload

{
  "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"
    }
  }
}

Example: subscription_cancelled Payload

{
  "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"
    }
  }
}

Webhook Security & Signature Verification

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.

The X-Signature Header

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
Always Verify Signatures in Production

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.

Verification in Node.js (Express)

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}`);
  }
}
Use crypto.timingSafeEqual — Not ===

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.

Configuring Your Webhook URL

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.

  1. Log in to your LemonSqueezy dashboard at app.lemonsqueezy.com.
  2. Navigate to Settings → Webhooks.
  3. Click Add Webhook.
  4. Enter your webhook endpoint URL. For production Gridlock, this is the public address of your server — typically something like https://yourdomain.com/api/billing/webhook.
  5. Under Events, select:
    • subscription_created
    • subscription_updated
    • subscription_cancelled
    • order_created
  6. Copy the Signing Secret from the webhook configuration page and store it as the LEMONSQUEEZY_WEBHOOK_SECRET environment variable on your server. This is the shared secret used for HMAC-SHA256 signature verification.
  7. Click Save Webhook.
Your Endpoint Must Be Publicly Reachable

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.

Retry Logic & Failure Handling

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.

Attempt 1
Immediate
Retry 1
1 min
Retry 2
5 min
Retry 3
30 min
Retry 4
2 hrs
Retry 5
24 hrs

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.

Respond Fast — Process Asynchronously

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.

Idempotency & Event Deduplication

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())
  }
}
Persist Deduplication State

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.

Testing Webhooks Locally

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.

Using ngrok

# 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

Using Cloudflare Tunnel (Recommended for Persistent Development)

# 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

Sending a Test Event from LemonSqueezy

  1. Set your ngrok or Cloudflare URL as the webhook URL in LemonSqueezy Settings → Webhooks.
  2. In LemonSqueezy, click the webhook row and then Send Test.
  3. LemonSqueezy will POST a test payload to your endpoint. Check your server logs to confirm receipt and successful signature verification.
  4. Switch to Test Mode in LemonSqueezy to run full checkout flows without charging real cards. Test mode events have meta.test_mode: true in the payload.
Test Mode vs. Live Mode

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.

Error Handling in Webhook Handlers

A robust webhook handler handles errors gracefully without exposing internal details and without silently swallowing failures that require human attention.

What to Return on Errors

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.
Never Return 5xx for Logic Errors

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.

Logging and Alerting

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;
  }
}