Monitor your checkout so a broken payment step can’t look like no demand
A broken checkout does not look like a bug. It looks like nobody wants to buy. Catch it with four checks: call your payment provider's API with the live key every day, ping a heartbeat from your payment webhook so a quiet sales window alerts you, watch the pricing page for the buy button, and calendar the key's expiry.
What can go wrong with checkout and payments
- The API key expired or was revoked. Your server creates the checkout with a key that no longer works. The provider answers 401 or 403, your code logs it, and the visitor sees a button that does nothing. One Indie Hackers founder lost six days of sales this way before he noticed. Keys can carry an expiry date: when you create a Paddle API key you can set one, and Paddle's docs give a default of 90 days and a maximum of one year. Keys are also revoked by a teammate cleaning up, or rotated in one place and not the other.
- The price or product was archived. Someone archives an old price in the dashboard, or a test price ID ships to production. The checkout call fails with a not found or inactive error while every page still loads.
- The webhook stopped arriving. Payments go through, but the webhook that unlocks the account fails: a changed endpoint, a rotated signing secret, or a deploy that broke the handler. Customers pay and get nothing, and the provider retries quietly for days.
- The page changed. A deploy removes the buy button, a script blocker or CSP rule stops the provider's checkout script from loading, or a sandbox token ships to production. The home page is fine, so uptime checks stay green.
- The account was paused. The provider holds payouts or pauses the account for verification. Mails about it go to an inbox nobody reads.
The signals that matter, with alert thresholds
| Signal | Alert when | Why it matters |
|---|---|---|
| Authenticated API call with the live key | non-2xx, or the price is not active | The key expired, was revoked, or the price was archived. |
| Heartbeat from the payment webhook | no ping within your normal gap between sales plus grace | Sales stopped, or the webhook did. Both need a look. |
| Keyword on the pricing or checkout page | the buy button text or the checkout script disappears | A deploy broke the page while the site stayed up. |
| Uptime of the webhook endpoint | non-2xx or TLS error from outside | The provider cannot deliver events. |
| Key expiry date (calendar) | 14 days before it | Rotate before it breaks, not after. |
Thresholds are starting points. Tune them after a week of data: an alert nobody acts on should be raised or removed.
How to check checkout and payments by hand
curl -sS -H "Authorization: Bearer $PADDLE_API_KEY" https://api.paddle.com/prices/pri_YOUR_PRICE_ID | jq '.data.status, .error.code'
curl -sS -u "$STRIPE_KEY:" https://api.stripe.com/v1/balance | jq '.object, .error.type'
curl -sS -u "$RAZORPAY_KEY_ID:$RAZORPAY_KEY_SECRET" "https://api.razorpay.com/v1/payments?count=1" | jq '.entity, .error.code'
curl -sS https://example.com/pricing | grep -c -i 'buy now'
A working key prints active, balance or collection; a broken one prints an error code. Use Paddle's sandbox host (sandbox-api.paddle.com) with a sandbox key to try it first.
1. Call the payment API with the live key every day
This is the check that would have caught the six-day outage. Create an API flow in Vigil and paste the steps for your provider. Each step is a real request; assertions are checked against the response, and extract passes a value to the next step as {{name}}. Replace the placeholders with your own key and IDs. Run it every hour or once a day.
[
{"name": "Paddle key works, price is active", "method": "GET",
"url": "https://api.paddle.com/prices/pri_YOUR_PRICE_ID",
"headers": {"Authorization": "Bearer YOUR_PADDLE_API_KEY"},
"expect_status": "200",
"assertions": "json data.status == active",
"extract": {"product_id": "json:data.product_id"}},
{"name": "Its product is active", "method": "GET",
"url": "https://api.paddle.com/products/{{product_id}}",
"headers": {"Authorization": "Bearer YOUR_PADDLE_API_KEY"},
"expect_status": "200",
"assertions": "json data.status == active"}
][
{"name": "Stripe key works", "method": "GET",
"url": "https://api.stripe.com/v1/balance",
"headers": {"Authorization": "Bearer YOUR_STRIPE_KEY"},
"expect_status": "200",
"assertions": "json object == balance"},
{"name": "Checkout price is active", "method": "GET",
"url": "https://api.stripe.com/v1/prices/price_YOUR_PRICE_ID",
"headers": {"Authorization": "Bearer YOUR_STRIPE_KEY"},
"expect_status": "200",
"assertions": "json active == true"}
][
{"name": "Razorpay key works", "method": "GET",
"url": "https://api.razorpay.com/v1/payments?count=1",
"headers": {"Authorization": "Basic BASE64_OF_KEY_ID_COLON_KEY_SECRET"},
"expect_status": "200",
"assertions": "json entity == collection"}
]printf '%s' 'rzp_live_YOUR_KEY_ID:YOUR_KEY_SECRET' | base64
Which key? Test the key your checkout code actually uses. A separate monitoring key proves nothing about the real one, because it will not expire on the same day. For Stripe you can create a restricted key with read access to Balance and Prices for monitoring, but then also calendar the real key's expiry (step 4).
Don't want the live key outside your server? Add a small route to your app that makes the same call with the key it already has, and point a plain API check at it with the assertion json payments == ok. The key never leaves your server, and the check tests the exact key and code path your checkout uses.
# Option B: keep the key on your server. A route that makes the same call your checkout makes:
@app.get("/healthz/payments")
def payments_health():
r = httpx.get(f"https://api.paddle.com/prices/{PRICE_ID}",
headers={"Authorization": f"Bearer {settings.PADDLE_API_KEY}"}, timeout=10)
ok = r.status_code == 200 and r.json()["data"]["status"] == "active"
return JSONResponse({"payments": "ok" if ok else "failing"}, status_code=200 if ok else 503)2. Ping a heartbeat from your payment webhook
The API check proves you can take money. A heartbeat proves you did. After your webhook handler has verified and processed a paid order, it pings a Vigil heartbeat URL. If no ping arrives within your normal gap between sales, you get an alert. It also catches a broken webhook: payments that succeed but never unlock anything.
import urllib.request
HEARTBEAT_URL = "https://puzaricloud.in/ping/<token>"
def ping_sale_heartbeat():
"""Call this after a paid-order webhook was verified and processed."""
try:
urllib.request.urlopen(HEARTBEAT_URL, timeout=5)
except Exception:
pass # never let monitoring break the webhook itself
# Paddle: event_type == "transaction.completed"
# Stripe: event.type == "checkout.session.completed"
# Razorpay: event == "payment.captured"// Node 18 and later: after the webhook is verified and the order is saved
fetch("https://puzaricloud.in/ping/<token>", { signal: AbortSignal.timeout(5000) }).catch(() => {});Set the heartbeat period to the longest normal gap between sales. If you sell a few times a day, 24 hours with a grace of a few hours works. If sales are rarer, use 3 or 7 days, and lean on the daily API check for fast detection. An alert here does not always mean something broke; sometimes it is a slow week. But it is a slow week you now know about on day one, with the API check telling you whether the checkout still works.
3. Watch the pricing page for the buy button
Create a Keyword monitor on your pricing or checkout page with the rule disappears and the text of your buy button, for example Buy now, or the provider's script: cdn.paddle.com/paddle/v2/paddle.js, js.stripe.com or checkout.razorpay.com/v1/checkout.js. You get an alert when a deploy removes it.
A keyword monitor reads the HTML your server sends. If the button is drawn by JavaScript after the page loads, put the check on the script tag instead, or use a browser check, which runs the page in a real browser and can click the button.
4. Put the key's expiry date in your calendar
When you create or rotate a key, write its expiry date down where you will see it: a calendar event 14 days before, sent to an address you read. The daily API check catches the day it breaks; the calendar entry stops it from breaking at all. When you rotate, update every place the key lives (production, staging, the CI secret and the Vigil flow) and run the flow by hand once.
On Paddle you can also subscribe to the api_key.expiring webhook, which Paddle sends seven days before a key expires, and api_key.expired. Send them somewhere you read, not only to your app's log. (Paddle: API key expiration)
No monitoring tool? A cron job and Healthchecks.io
If you have a server with cron, you can get the most important check without any monitoring tool: call the API with the live key once a day and ping a free Healthchecks.io check only when it worked. If the key breaks, the ping stops and Healthchecks.io emails you.
# once a day at 09:00: call the provider with the live key, ping Healthchecks.io only if it worked 0 9 * * * curl -fsS -m 20 -o /dev/null -H "Authorization: Bearer $PADDLE_API_KEY" "https://api.paddle.com/prices/pri_YOUR_PRICE_ID" && curl -fsS -m 10 --retry 3 -o /dev/null https://hc-ping.com/<your-check-uuid> # Stripe: same idea 0 9 * * * curl -fsS -m 20 -o /dev/null -u "$STRIPE_KEY:" https://api.stripe.com/v1/balance && curl -fsS -m 10 --retry 3 -o /dev/null https://hc-ping.com/<your-check-uuid> # Razorpay: same idea 0 9 * * * curl -fsS -m 20 -o /dev/null -u "$RAZORPAY_KEY_ID:$RAZORPAY_KEY_SECRET" "https://api.razorpay.com/v1/payments?count=1" && curl -fsS -m 10 --retry 3 -o /dev/null https://hc-ping.com/<your-check-uuid>
curl -f treats a 401 or 404 as a failure, so the second curl only runs when the provider accepted the key. Vigil's heartbeat URLs use the same format, so you can point the same line at Vigil later.
How to watch checkout and payments with Vigil
- In New monitor choose API flow, paste the steps for your provider from step 1, replace the placeholders, and set the frequency to Every hour or Once a day. Every failure is confirmed by a second run before you are alerted.
- Create a Heartbeat named "Paid order webhook" with a period of your longest normal gap between sales and a grace of a few hours. Call its ping URL, https://puzaricloud.in/ping/<token>, from your webhook handler.
- Create a Keyword monitor on your pricing page with the rule disappears and your buy button text or the provider's checkout script.
- Add an API check on your webhook URL with the status it gives a plain GET as the expected status (often 405), so you know the endpoint is reachable from outside.
- Send these alerts somewhere you look every day: Telegram, Slack, Discord, email or push. All four checks fit on the free plan, which includes 2 API flows and 20 monitors.
Every alert goes to the channels you pick: email, Slack, Telegram, Discord, Microsoft Teams, Pushover, webhooks or on-call paging. Failures are confirmed by a second check before anyone is woken up.
checkout and payments monitoring FAQ
Is it safe to put a live payment key in a monitoring tool?
Any copy of a live key is a risk, so keep it small. Two ways: use a restricted, read-only key where the provider supports one (Stripe does), or use option B in step 1, a health route in your own app, so the key never leaves your server.
How often should the API check run?
Once an hour is a good default: a broken key costs you at most an hour of sales, and it is 24 requests a day, far below any provider's rate limit. Once a day is fine if you sell a few times a week.
My sales are irregular. Will the webhook heartbeat cry wolf?
Set its period to your longest normal gap, even a week. Its job is to tell you that sales have stopped, not to judge a slow day. The daily API check is the fast signal; the heartbeat is the backstop that also catches a broken webhook.
Can I do this for free?
Yes. Vigil's free plan includes 2 API flows, heartbeats and keyword checks, with business use allowed. Without any tool, the cron and Healthchecks.io lines above cover the key check.