Listening to LemonSqueezy Javascript Events
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeRight now, every time we want to save a LemonSqueezy customer ID on the corresponding user entity locally, we have to configure our webhooks. Ngrok definitely helps, but it's still a bit of a pain. We need to run Ngrok in the background before we start receiving webhooks. And we still need to update the webhook URL every time we restart the Ngrok agent if we don't have a paid Ngrok plan. That's... not ideal.
Let's explore an alternative way - listening to LemonSqueezy JavaScript events and set the customer ID on a successful checkout. LemonSqueezy has a special event for this! Open the docs, go to "Guides", find "Using Lemon.js" on the left, and on the right, click on "Handling events".
Here, we can see that when the checkout's successful, LemonSqueezy fires a Checkout.Success
event. They even give us some sample code for how to handle it. This returns a bunch of useful data, including the customer ID we're looking for.
Listening to the LemonSqueezy Checkout.Success
Event
Time to get to work! Open assets/controllers/lemon-squeezy_controller.js
. Look for the connect()
method and, at the bottom, start with window.LemonSqueezy.Setup()
. Inside, pass eventHandler: (data) => {}
, and inside that, write if (data.event === 'Checkout.Success')
. Get the customer ID with data.data.customer_id
and put it on a lsCustomerId
variable.
// ... lines 1 - 2 | |
export default class extends Controller { | |
// ... lines 4 - 7 | |
connect() { | |
// ... lines 9 - 17 | |
window.LemonSqueezy.Setup({ | |
eventHandler: (data) => { | |
if (data.event === 'Checkout.Success') { | |
// ... line 21 | |
const lsCustomerId = data.data.customer_id; | |
this.#handleCheckout(lsCustomerId); | |
} | |
}, | |
}); | |
} | |
// ... lines 28 - 80 | |
} |
We'll pass the ID to this.#handleCheckout()
. This doesn't exist yet, so create it below, with lsCustomerId
as a parameter.
// ... lines 1 - 2 | |
export default class extends Controller { | |
// ... lines 4 - 66 | |
#handleCheckout(lsCustomerId) { | |
} | |
// ... lines 69 - 80 | |
} |
Adding a new Endpoint for Creating Checkout URL
Next, we need to create an endpoint in our app that will handle and save the customer ID for the user. To do that, open src/Controller/OrderController.php
and create a new method: public function handleCheckout()
. Register this #[Route]
with a path - /checkout/handle
and name it app_order_checkout_handle
. We want this method to only work for POST
requests.
This needs a request and the current user, so inject Request $request
and #[CurrentUser] User $user
.
// ... lines 1 - 17 | |
class OrderController extends AbstractController | |
{ | |
// ... lines 20 - 79 | |
'/checkout/handle', name: 'app_order_checkout_handle', methods: ['POST']) | (|
public function handleCheckout( | |
Request $request, | |
#[CurrentUser] User $user, | |
): Response { | |
} | |
// ... lines 87 - 108 | |
} |
We'll assume that the ID will be passed via a POST request as lsCustomerId
, so retrieve it from the request with $request->request->get('lsCustomerId')
. Below, set it on the user with $user->setLsCustomerId($lsCustomerId)
.
// ... lines 1 - 80 | |
public function handleCheckout( | |
// ... lines 82 - 83 | |
): Response { | |
$lsCustomerId = $request->request->get('lsCustomerId'); | |
$user->setLsCustomerId($lsCustomerId); | |
} | |
// ... lines 88 - 111 |
To actually save it to the database, we also need to inject EntityManagerInterface $entityManager
and, at the end, call $entityManager->flush()
. Finish with return $this->json([])
. We don't need to return actual data here - a successful response is enough.
// ... lines 1 - 81 | |
public function handleCheckout( | |
// ... line 83 | |
EntityManagerInterface $entityManager, | |
// ... line 85 | |
): Response { | |
// ... lines 87 - 89 | |
$entityManager->flush(); | |
return $this->json([]); | |
} | |
// ... lines 94 - 117 |
Updating the Stimulus Controller
In the Stimulus controller, add a new value called checkoutHandleUrl:
String and pass the URL from the template. To do that, in templates/order/cart.html.twig
, add data-lemon-squeezy-checkout-handle-url-value=""
and pass the URL with {{ path('app_order_checkout_handle') }}
.
// ... lines 1 - 2 | |
export default class extends Controller { | |
static values = { | |
// ... line 5 | |
checkoutHandleUrl: String, | |
}; | |
// ... lines 8 - 81 | |
} |
// ... lines 1 - 4 | |
{% block content %} | |
// ... lines 6 - 47 | |
<a class="w-[345px] flex ml-2 rounded-3xl border border-[#50272B] bg-[#4F272B] hover:bg-[#1C0000] shadow-inner poppins-bold text-white py-3 pl-5 uppercase" | |
// ... line 49 | |
data-controller="lemon-squeezy" | |
// ... lines 51 - 52 | |
data-lemon-squeezy-checkout-handle-url-value="{{ path('app_order_checkout_handle') }}" | |
> | |
Checkout with LemonSqueezy | |
// ... line 56 | |
</a> | |
// ... lines 58 - 70 | |
{% endblock %} |
With the value set, back in the controller, in #handleCheckout()
, make an AJAX call with fetch()
, passing this.checkoutHandleUrlValue
. For options, use method: 'POST'
, and for headers, 'Content-Type':
'application/x-www-form-urlencoded'. This allows us to fetch values with $request->request->get()
- no need to json_decode()
the request. For the body
, pass new URLSearchParams()
with lsCustomerId: lsCustomerId
.
// ... lines 1 - 2 | |
export default class extends Controller { | |
// ... lines 4 - 67 | |
#handleCheckout(lsCustomerId) { | |
fetch(this.checkoutHandleUrlValue, { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/x-www-form-urlencoded', | |
}, | |
body: new URLSearchParams({ | |
lsCustomerId: lsCustomerId, | |
}), | |
}) | |
// ... lines 78 - 90 | |
} | |
// ... lines 92 - 103 | |
} |
Chain this fetch()
call with .then()
. Inside, expect response => {}
. If the response is not okay, throw a new Error()
with the message:
"Network response was not ok" + response.statusText
.
Below, return response.json()
. This will give us the decoded JSON object in the next .then()
. Accept data => {}
, and inside, just leave a comment reminding us that there's nothing to do here, because we don't return any data from that endpoint. But, just in case something goes wrong, chain .catch()
with console.error('Fetch error:', error)
.
// ... lines 1 - 67 | |
#handleCheckout(lsCustomerId) { | |
fetch(this.checkoutHandleUrlValue, { | |
// ... lines 70 - 76 | |
}) | |
.then(response => { | |
if (!response.ok) { | |
throw new Error("Network response was not ok " + response.statusText); | |
} | |
return response.json(); | |
}) | |
.then(data => { | |
// Nothing to do | |
}) | |
.catch(error => { | |
console.error('Fetch error:', error); | |
}); | |
} | |
// ... lines 92 - 105 |
Testing and Fixing Errors
This looks good, so let's give it a try! Over on our site, add a product to the cart, and open the "Console" tab in the Dev Tools. Whoops... an error.
Uncaught TypeError: Cannot read properties of undefined (reading 'Setup')
Looks like we've started using LemonSqueezy faster than its script was downloaded. Let's do a little trick and wrap this code in script.addEventListener()
. Listen for the load
event, pass a function as the second argument, and insert our code there.
// ... lines 1 - 8 | |
connect() { | |
// ... lines 10 - 18 | |
script.addEventListener('load', () => { | |
window.LemonSqueezy.Setup({ | |
eventHandler: (data) => { | |
if (data.event === 'Checkout.Success') { | |
// ... line 23 | |
const lsCustomerId = data.data.customer_id; | |
this.#handleCheckout(lsCustomerId); | |
} | |
}, | |
}); | |
}); | |
} | |
// ... lines 31 - 107 |
If we refresh the page again... dang... we get the same error.
Okay, it looks like we should try to instantiate LemonSqueezy manually first. Before the problem line, write window.createLemonSqueezy()
. Add a little comment above to remind future us what we're doing here.
// ... lines 1 - 8 | |
connect() { | |
// ... lines 10 - 18 | |
script.addEventListener('load', () => { | |
// The script has loaded, now you can safely call createLemonSqueezy() | |
window.createLemonSqueezy(); | |
// ... lines 22 - 31 | |
}); | |
} | |
// ... lines 34 - 110 |
Refresh again, and... no errors! Perfect! Let's quickly add console.log(data)
to our code so we'll know if we hit that if
on Checkout.Success
.
// ... lines 1 - 8 | |
connect() { | |
// ... lines 10 - 18 | |
script.addEventListener('load', () => { | |
// ... lines 20 - 22 | |
window.LemonSqueezy.Setup({ | |
eventHandler: (data) => { | |
if (data.event === 'Checkout.Success') { | |
console.log(data); | |
// ... lines 27 - 28 | |
} | |
}, | |
}); | |
}); | |
} | |
// ... lines 34 - 110 |
Refresh our site one more time to load the changes... and click "Checkout with LemonSqueezy". Fill in payment info, billing address... click "Pay", and... we see the success message! And in the console... we can see the data, so our code was hit. So... did this work?
At your terminal, check the database with:
bin/console doctrine:query:sql "SELECT * FROM user"
It didn't! It says that the lsCustomerId
value is "undefined". Hmmm, sounds like we're using a bad path for the customer ID. If we double-check our dump... yep. The path the docs gave us is incorrect.
Change the path to data.data.order.data.attributes.customer_id
, and try this one more time.
// ... lines 1 - 8 | |
connect() { | |
// ... lines 10 - 18 | |
script.addEventListener('load', () => { | |
// ... lines 20 - 22 | |
window.LemonSqueezy.Setup({ | |
eventHandler: (data) => { | |
if (data.event === 'Checkout.Success') { | |
// ... line 26 | |
const lsCustomerId = data.data.order.data.attributes.customer_id; | |
// ... line 28 | |
} | |
}, | |
}); | |
}); | |
} | |
// ... lines 34 - 110 |
Refresh the page, go through the checkout process again (I'll speed through this to save time), and... success! Now, back in our terminal, rerun the query:
bin/console doctrine:query:sql "SELECT * FROM user"
And... Yes! The customer ID was set correctly! We don't need that console.log()
anymore, so we can delete it, along with another one that we missed in #openOverlay
.
So, even if we don't have Ngrok running, we're still able to sync the LemonSqueezy customer ID with the user via JavaScript events. This approach simplifies local development a bit, but both ways are totally valid.
Next: Let's tackle some potential security issues by preventing customer ID hijacking.