# Guide to Lago Webhooks: Features and Best Practices

Lago webhooks keep your product in step with your billing. When Lago finalises an invoice, records a payment, starts or terminates a subscription, or sees a prepaid wallet run dry, it posts a message to your endpoint, so your application can react without polling the API.

This guide covers how Lago webhook endpoints are created and signed, the messages and their format, Lago's retry behaviour, and the best practices for running a Lago receiver in production.

## What are Lago webhooks?

A Lago organisation can have up to 10 webhook endpoints. Each one has a URL, a list of message types and a signature algorithm. Lago sends every matching message to every endpoint as a `POST` with a JSON body.

Each endpoint uses one of two signature schemes, and the choice matters. **JWT**, Lago's default, puts an RS256 JSON Web Token in `X-Lago-Signature` that you check with Lago's public key. **HMAC** puts a base64 HMAC-SHA256 of the raw body in the same header, keyed with your organisation's HMAC Signature Token. `X-Lago-Signature-Algorithm` says which one a message uses. Lago's own guide describes JWT as having "a possibly lengthy payload header and potential size limits", and HMAC as having "a shorter payload header and no size restrictions".

Hookdeck's Lago source type verifies the HMAC scheme only, so use HMAC for any endpoint that points at Hookdeck.

## Lago webhook features

| Feature | Details |
| --- | --- |
| Configuration | Lago app (**Developers > Webhooks**) or `POST /api/v1/webhook_endpoints` |
| Endpoints | Up to 10 per organisation, each with its own message types and signature algorithm |
| Message type | `webhook_type` in the body. No header carries it |
| Signature header | `X-Lago-Signature`, with `X-Lago-Signature-Algorithm` set to `jwt` or `hmac` |
| JWT scheme (default) | RS256 JWT, issuer `https://api.getlago.com`, whose `data` claim must equal the body |
| HMAC scheme | HMAC-SHA256 of the raw body, base64 |
| HMAC key | The organisation's HMAC Signature Token |
| Idempotency key | `X-Lago-Unique-Key`, a UUID per message |
| Replay protection | None documented. Neither scheme asks you to check a timestamp |
| Acknowledgement | `200`, `201`, `202` or `204`. Anything else, or a network error, fails |
| Retries | Up to 3 attempts per message (the first call plus 2 retries), with growing waits |
| Self-hosted settings | `LAGO_WEBHOOK_ATTEMPTS` (default 3) and `LAGO_WEBHOOK_TIMEOUT_SECONDS` (default 30) |
| Logs | Per-endpoint webhook logs with the unique key, response code and retry count. Failed messages can be retried from there |

Lago publishes no source IP list for webhooks, and does not state the response timeout or retry intervals for Lago Cloud.

## Common events

Lago's messages page lists 82 types and says "new types are added over time, so make sure your integration accepts a type it does not know yet". These are the ones most integrations start with:

| Message | Sent when |
| --- | --- |
| `invoice.created` | An invoice is finalised, so you can carry on with billing actions |
| `invoice.generated` | The PDF for a customer invoice has been generated |
| `invoice.payment_status_updated` | An invoice's payment status changes, based on information from the payment provider |
| `invoice.payment_failure` | Payment fails, usually because the customer has no valid payment method at the provider |
| `invoice.payment_overdue` | An invoice's payment is considered overdue |
| `payment.succeeded` | A payment provider confirms a payment collected for an invoice |
| `payment.requires_action` | A payment needs extra confirmation, typically 3D Secure. The `url` field holds the checkout link |
| `subscription.started` | A subscription starts. `previous_plan_code` is filled after an upgrade or downgrade |
| `subscription.terminated` | A subscription is terminated. `next_plan_code` is filled after an upgrade or downgrade |
| `subscription.termination_alert` | 45 and 15 days before a subscription's end date |
| `subscription.trial_ended` | A subscription's free trial ends |
| `subscription.usage_threshold_reached` | A progressive billing threshold is crossed |
| `alert.triggered` | A usage alert threshold is crossed |
| `wallet.depleted_ongoing_balance` | A wallet's ongoing balance is zero or negative |
| `events.errors` | The hourly health check finds received usage events that fail validation |

The other types cover add-ons, billable metrics, coupons, credit notes and refunds, customers and their payment, accounting, CRM and tax provider integrations, dunning campaigns, features, payment receipts, plans, quotes and order forms, fees, and wallet transactions. `event.error` is deprecated in favour of `events.errors`, but Lago still sends it.

Every message has the same shape. `object_type` names the key that holds the object, so an `invoice.created` message carries an `invoice` key. Here it is with the invoice cut down to a few of its fields:

```json
{
  "webhook_type": "invoice.created",
  "object_type": "invoice",
  "organization_id": "1a901a90-1a90-1a90-1a90-1a901a901a90",
  "invoice": {
    "lago_id": "1a901a90-1a90-1a90-1a90-1a901a901a90",
    "number": "LAG-1234-001-002",
    "status": "finalized",
    "currency": "EUR",
    "total_amount_cents": 100
  }
}

```

Route on `webhook_type`, not `object_type`. Every message about an invoice has `object_type` set to `invoice`, whether it was created, paid or voided.

## Setting up Lago webhooks

1. In the Lago app, open **Developers > Webhooks** and click **Add a webhook**.
2. Enter a name and your endpoint URL.
3. Choose **HMAC** as the signature. Lago selects JWT unless you change it, and Hookdeck's Lago source type can't verify JWT messages: every one of them will fail verification.
4. Select the message types to receive, then click **Add webhook**.
5. On the same **Webhooks** tab, find the **HMAC Signature Token**, reveal it and copy it. This is the HMAC key. It is not your Lago API key. Store it server-side, for example as `LAGO_HMAC_KEY`, or paste it into your Hookdeck Lago source.

To do the same through the API, pass `"signature_algo": "hmac"` explicitly, because the API also defaults to `jwt`:

```bash
curl --request POST "https://api.getlago.com/api/v1/webhook_endpoints" \
  --header "Authorization: Bearer $LAGO_API_KEY" \
  --header "Content-Type: application/json" \
  --data-raw '{
    "webhook_endpoint": {
      "webhook_url": "https://example.com/webhooks/lago",
      "signature_algo": "hmac",
      "name": "billing-sync",
      "event_types": ["invoice.created", "invoice.payment_status_updated", "subscription.terminated"]
    }
  }'

```

`event_types` set to `null` or `["*"]` sends every type, and an empty array stops all delivery. If an endpoint already exists with JWT, switch it with `PUT /api/v1/webhook_endpoints/{lago_id}` and `"signature_algo": "hmac"`.

On a self-hosted Lago, use your own instance's API URL in place of `https://api.getlago.com`.

For local development, the [Hookdeck CLI](/docs/cli) (`hookdeck listen 3000 lago --path /webhooks/lago`) gives you a public HTTPS URL that forwards to your local server, with no account required.

## Securing Lago webhooks

In HMAC mode, compute an HMAC-SHA256 of the raw body with the HMAC Signature Token, base64-encode it and compare it in constant time with `X-Lago-Signature`. Reject messages whose `X-Lago-Signature-Algorithm` isn't `hmac`, so a JWT endpoint shows up as an error rather than a confusing mismatch.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyLagoSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('base64');
  const received = Buffer.from(signatureHeader, 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/lago', express.raw({ type: '*/*' }), (req, res) => {
  if (req.get('x-lago-signature-algorithm') !== 'hmac') {
    return res.status(400).send('Expected an HMAC-signed Lago endpoint');
  }
  if (!verifyLagoSignature(req.body, req.get('x-lago-signature'), process.env.LAGO_HMAC_KEY)) {
    return res.sendStatus(401);
  }

  const message = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on the X-Lago-Unique-Key header, route on message.webhook_type,
  // then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac
import os

def verify_lago_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    digest = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
    expected = base64.b64encode(digest).decode("utf-8")
    return hmac.compare_digest(expected.encode("utf-8"), signature_header.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# if request.headers.get("x-lago-signature-algorithm") != "hmac": reject
# verify_lago_signature(raw_body, request.headers.get("x-lago-signature"),
#                       os.environ["LAGO_HMAC_KEY"])

```

Lago's own samples compare the strings with `==`. Use a constant-time comparison as above.

To check your verifier, use the key `lago-test-hmac-token` and this exact body:

```
{"webhook_type":"invoice.created","object_type":"invoice","organization_id":"1a901a90-1a90-1a90-1a90-1a901a901a90","invoice":{"lago_id":"1a901a90-1a90-1a90-1a90-1a901a901a90","number":"LAG-1234-001-002","status":"finalized","currency":"EUR","total_amount_cents":100}}

```

The header should be:

```
0GQVqGVe8NW0mLQLyBtgYlvXx9jrMCwKQzi9OwD3eI8=

```

If you must stay on JWT, decode `X-Lago-Signature` with the RS256 public key from `GET /api/v1/webhooks/json_public_key`, require the issuer `https://api.getlago.com`, and check that the token's `data` claim equals the body. Lago suggests caching the public key rather than fetching it for every message. That check has to live in your own receiver.

> **Make Lago webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies `X-Lago-Signature` with your HMAC Signature Token, accepts each message as soon as it arrives, and durably queues it, so a billing event is never lost after Lago's third attempt.

## Lago webhook limitations and pain points

### JWT by default

**The Problem:** Every Lago message fails verification on a new endpoint, though the HMAC token is copied correctly.

**Why It Happens:** Lago signs with JWT unless you choose HMAC, both in the app and in the API. A JWT endpoint puts an RS256 token in `X-Lago-Signature`, so an HMAC check of the body can never match it.

**Workarounds:**

* Check `X-Lago-Signature-Algorithm` on a failing message. If it says `jwt`, switch the endpoint to HMAC.
* Always send `"signature_algo": "hmac"` when you create endpoints through the API.

**How Hookdeck Can Help:** Hookdeck's Lago source type verifies HMAC messages. Once the endpoint is switched, Hookdeck checks every message before it reaches your service, and messages that fail are not delivered.

### Only three attempts

**The Problem:** A short outage on your side leaves invoices and subscription changes that never reached your system.

**Why It Happens:** Lago makes up to 3 attempts per message, the first call plus 2 retries, with growing waits. On Lago Cloud, the intervals are not published. After the last attempt, the message stays in the webhook logs as failed.

**Workarounds:**

* Watch the webhook logs, filter by status, and retry failed messages from there.
* On self-hosted Lago, raise `LAGO_WEBHOOK_ATTEMPTS`.
* Reconcile important objects, such as invoices and subscriptions, against the Lago API on a schedule.

**How Hookdeck Can Help:** Hookdeck accepts each message on Lago's first attempt and stores it. Delivery to your service is then [retried](/docs/retries) on a schedule you set, failures raise [issues](/docs/issues), and you can replay messages once your service is back.

### Duplicates

**The Problem:** A customer receives two "payment failed" emails, or a wallet top-up is applied twice.

**Why It Happens:** Lago resends a message whenever it doesn't get an acknowledgement in time, even if your handler finished the work.

**Workarounds:**

* Record each `X-Lago-Unique-Key` and skip messages whose key you have already processed. Lago tells you to "use it to process a message only once".

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeated deliveries based on the `X-Lago-Unique-Key` header before they reach your handler.

### No documented ordering

**The Problem:** A `subscription.terminated` is handled before the `subscription.started` for the same customer.

**Why It Happens:** Lago does not say that messages arrive in order, and a retried message can arrive after newer ones.

**Workarounds:**

* Before changing access or sending a customer email, fetch the current subscription or invoice from the Lago API.
* Compare timestamps on the object with what you have stored, and ignore stale updates.

**How Hookdeck Can Help:** Hookdeck delivers each message with full visibility of its attempts, and you can cap the delivery rate to your service. Your handler still needs to re-read state rather than rely on arrival order.

### One endpoint, many consumers

**The Problem:** Finance, product access and customer messaging all need different Lago messages, and the 10-endpoint limit gets tight.

**Why It Happens:** Each Lago endpoint is a separate URL with its own list of types, and an organisation can have only 10.

**Workarounds:**

* Point one endpoint at a router you run, and fan messages out from there.

**How Hookdeck Can Help:** Send one Lago endpoint to a Hookdeck source and create a connection for each service, with [filters](/docs/filters) on `webhook_type` so each one receives only the messages it needs.

## Best practices

### Verify against the raw body

The HMAC covers the exact bytes Lago sent. Mount `express.raw({ type: '*/*' })` in Express, use `Buffer.from(await request.arrayBuffer())` in a Next.js route handler, or read `await request.body()` in FastAPI. Parse JSON only after the check.

### Deduplicate on `X-Lago-Unique-Key`

Store each key with a unique constraint and skip repeats. Our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency) covers the patterns.

### Route on `webhook_type`, and accept unknown types

Switch on `webhook_type` and return `200` for anything you don't handle, as Lago asks, so new message types never count as failures.

### Acknowledge fast and process asynchronously

Verify, store, return `200`, then do the work from a queue. Lago's self-hosted default timeout is 30 seconds, and the cloud value is not published. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Keep the HMAC token apart from the API key

The HMAC Signature Token only verifies messages. The API key can change billing data. Store them separately, and never use the API key as the HMAC secret.

## Conclusion

Lago signs every webhook, but with JWT unless you choose otherwise. Create endpoints with the HMAC signature, verify `X-Lago-Signature` as a base64 HMAC-SHA256 of the raw body with your organisation's HMAC Signature Token, deduplicate on `X-Lago-Unique-Key`, route on `webhook_type`, and watch the webhook logs, because Lago stops after three attempts.

[Hookdeck Event Gateway](https://hookdeck.com) verifies each HMAC signature, accepts every message on Lago's first attempt, fans messages out to the services that need them and holds each one until it has been processed.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Lago webhooks reliably in minutes.