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 | 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_ (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_ | 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_ | 45 and 15 days before a subscription's end date |
subscription.trial_ended | A subscription's free trial ends |
subscription.usage_ | A progressive billing threshold is crossed |
alert.triggered | A usage alert threshold is crossed |
wallet.depleted_ | 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:
{
"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
- In the Lago app, open Developers > Webhooks and click Add a webhook.
- Enter a name and your endpoint URL.
- 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.
- Select the message types to receive, then click Add webhook.
- 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:
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_ 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 (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.
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:
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_, 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 verifies
X-Lago-Signaturewith 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-Algorithmon a failing message. If it saysjwt, 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 on a schedule you set, failures raise 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-Keyand 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 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 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 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.
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 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 for free and handle Lago webhooks reliably in minutes.