Lemon Squeezy Integration
Receive and route Lemon Squeezy webhooks for orders, subscriptions and license keys.
Setup
1. Create a Source in Hookbase
curl -X POST https://api.hookbase.app/api/sources \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Lemon Squeezy Production",
"slug": "lemonsqueezy",
"provider": "lemonsqueezy",
"signingSecret": "your-lemonsqueezy-signing-secret"
}'Save your webhook URL:
https://api.hookbase.app/ingest/{orgSlug}/lemonsqueezy2. Create the Lemon Squeezy Webhook
Webhooks are configured per store, under Settings → Webhooks in the Lemon Squeezy dashboard.
- Add an endpoint and paste your Hookbase ingest URL as its callback URL
- Set the signing secret, and store the same value on your Hookbase source as
signingSecret - Select the events you want the endpoint to receive
The signing secret is a value you choose rather than one Lemon Squeezy generates, so it has to be entered in both places and match exactly. Each endpoint has its own secret; if you point two endpoints at one Hookbase source, only the one whose secret you stored verifies.
See Lemon Squeezy's signing documentation for the signature scheme.
3. Create Destinations and Routes
# Create a destination
curl -X POST https://api.hookbase.app/api/destinations \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Billing Service", "slug": "lemonsqueezy-billing", "url": "https://api.myapp.com/webhooks/lemonsqueezy"}'
# Create a route
curl -X POST https://api.hookbase.app/api/routes \
-H "Authorization: Bearer whr_your_api_key" \
-H "Content-Type: application/json" \
-d '{"name": "Lemon Squeezy to Billing", "sourceId": "src_...", "destinationId": "dst_..."}'A route carries one destination. To send the same events to both a billing service and a fulfilment worker, create two routes over the same source.
Signature Verification
Lemon Squeezy signs the raw request body with HMAC-SHA256 and sends the hex digest in the
X-Signature header. There is no prefix and no timestamp — the header value is the digest and nothing
else:
X-Signature: 8f1c0a2b3d4e5f60718293a4b5c6d7e8f9012a3b4c5d6e7f8091a2b3c4d5e6f7The secret is used exactly as you stored it — its UTF-8 bytes are the HMAC key. Nothing is stripped from it and nothing is base64-decoded first.
Hookbase verifies this automatically once the source has both fields set:
{
"provider": "lemonsqueezy",
"signingSecret": "your-lemonsqueezy-signing-secret"
}There is no timestamp in this scheme, so nothing expires and nothing needs a clock tolerance.
Info
A source left on custom also verifies this traffic, and that is not a coincidence. The custom
scheme reads X-Signature first of three header names, takes a hex HMAC-SHA256 of the raw body, and
tolerates the sha256= prefix being present or absent. For Lemon Squeezy that is the same
computation over the same header, so verification passes either way and you will see
signature_valid: true without ever setting the provider.
Set provider: "lemonsqueezy" anyway. custom exists for senders with no published scheme, so it is
deliberately loose — it accepts a value under two other header names and with an optional prefix, none
of which Lemon Squeezy sends. Naming the provider is what states, in one place, which scheme this
source is supposed to be receiving, and it keeps the source correct if custom is ever tightened.
Warning
X-Signature is a generic header name that other senders reuse with different algorithms. Segment,
for one, signs with HMAC-SHA1 under the same header. A digest in X-Signature therefore tells you
nothing about how it was produced, so do not point two different senders at one Hookbase source: the
source has exactly one provider and one signingSecret, and whichever sender does not match is
recorded unverified.
Once you have confirmed events are arriving with signature_valid: true, set
rejectInvalidSignatures: true on the source to have unverified requests refused with 401 instead
of stored.
Common Events
The event name is in the body, at meta.event_name.
| Event | Description |
|---|---|
order_created | A new order was placed |
order_refunded | An order was refunded |
subscription_created | A new subscription was created |
subscription_updated | A subscription was updated |
subscription_cancelled | A subscription was cancelled |
subscription_payment_success | A subscription payment succeeded |
subscription_payment_failed | A subscription payment failed |
license_key_created | A license key was created |
Order Created
{
"meta": {
"event_name": "order_created",
"custom_data": {}
},
"data": {
"type": "orders",
"id": "1",
"attributes": {
"store_id": 1,
"status": "paid",
"total": 999,
"total_formatted": "$9.99",
"currency": "USD",
"user_email": "customer@example.com",
"created_at": "2024-01-15T10:30:00Z"
}
}
}Subscription Created
{
"meta": {
"event_name": "subscription_created",
"custom_data": {}
},
"data": {
"type": "subscriptions",
"id": "1",
"attributes": {
"store_id": 1,
"status": "active",
"user_email": "customer@example.com",
"variant_name": "Pro Monthly",
"renews_at": "2024-02-15T10:30:00Z"
}
}
}Transform Examples
A javascript transform receives the parsed payload and returns the body Hookbase delivers.
Order Row for Your Billing Service
function transform(payload) {
const a = (payload.data && payload.data.attributes) || {};
return {
event: payload.meta ? payload.meta.event_name : null,
resource_type: payload.data ? payload.data.type : null,
resource_id: payload.data ? payload.data.id : null,
store_id: a.store_id || null,
status: a.status || null,
email: a.user_email || null,
// `total` is in the currency's smallest unit; keep it that way and let the reader divide.
total_minor_units: a.total === undefined ? null : a.total,
currency: a.currency || null,
occurred_at: a.created_at || null,
// custom_data is whatever you attached at checkout — usually your own user id.
custom_data: (payload.meta && payload.meta.custom_data) || {}
};
}Subscription State for Entitlements
function transform(payload) {
const a = (payload.data && payload.data.attributes) || {};
const event = payload.meta ? payload.meta.event_name : "";
return {
subscription_id: payload.data ? payload.data.id : null,
email: a.user_email || null,
plan: a.variant_name || null,
status: a.status || null,
renews_at: a.renews_at || null,
// Entitlement decisions read `status`, not the event name: an `updated` event can carry any
// state, and a cancelled subscription usually stays active until the period ends.
entitled: a.status === "active" || a.status === "on_trial",
event: event
};
}Filter Examples
Filter conditions read dotted paths out of the payload. logic must be AND or OR.
Subscription Events Only
{
"name": "Subscriptions",
"logic": "AND",
"conditions": [
{
"field": "meta.event_name",
"operator": "starts_with",
"value": "subscription_"
}
]
}Paid Orders Only
{
"name": "Paid Orders",
"logic": "AND",
"conditions": [
{
"field": "meta.event_name",
"operator": "equals",
"value": "order_created"
},
{
"field": "data.attributes.status",
"operator": "equals",
"value": "paid"
}
]
}Billing Problems
{
"name": "Billing Problems",
"logic": "OR",
"conditions": [
{
"field": "meta.event_name",
"operator": "equals",
"value": "subscription_payment_failed"
},
{
"field": "meta.event_name",
"operator": "equals",
"value": "order_refunded"
}
]
}Headers
| Header | Description |
|---|---|
X-Signature | Hex HMAC-SHA256 of the raw body, with no prefix |
Hookbase does not forward these headers to your destination. A delivery is a fresh request:
Hookbase sets Content-Type, User-Agent: Hookbase/1.0, X-Delivery-ID and X-Event-ID, then
adds the headers and auth you configured on the destination. Nothing Lemon Squeezy sent reaches your
handler as a header — read what you need out of the body, or set it on the destination yourself.
Of the incoming headers, only content-type, user-agent, x-github-event, x-gitlab-event and
stripe-signature are stored on the event, so those are the only ones visible later in the
dashboard or the API.
Troubleshooting
Signature Verification Failed
- Confirm the signing secret on the endpoint and the
signingSecreton the source are the same string. Because you choose this value, a typo in either place is possible and has no other symptom - Confirm the digest is hex. A base64 digest of the same bytes is 44 characters ending in
=and never matches - Confirm you are hashing the raw body. A proxy that reformats JSON on the way through invalidates the digest
- Confirm only one sender points at this source.
X-Signatureis a name several providers use — see the warning above - The
providerfield is not the cause here.lemonsqueezyandcustomcompute the same digest over the same header, so a failure under one fails under the other
No Events Arriving
- Check the endpoint's selected events cover what you are testing
- Confirm the callback URL matches your ingest URL exactly, including the org and source slugs
- Hookbase stores every request that reaches the ingest URL whether or not it verified, unless
rejectInvalidSignaturesis on. If no event is recorded at all, the request never arrived — check the callback URL before the signature
Duplicate Events
Hookbase has no provider event-id extractor for Lemon Squeezy, and the body carries no delivery id of
its own, so the default auto dedup strategy falls back to hashing the payload. Byte-identical retries
collapse into one; two genuine events that differ anywhere in the body do not. For your own idempotency
use data.type plus data.id plus meta.event_name — data.id alone repeats across the lifecycle of
one subscription.