Webhooks
Get a signed HTTPS request the moment a checkout or payment changes, instead of polling the API.
Add an endpoint in Dashboard → Developers → Webhooks: an HTTPS URL on your server, the mode (test or live) and the events it should receive. From then on Lollipay sends each matching event as a POST with a JSON body.
Events
| Event | Sent when |
|---|---|
checkout.created | A checkout session was created. |
checkout.completed | A buyer paid a checkout session. This is the one to fulfil orders on. |
checkout.expired | A checkout session ran out of time unpaid. It's sent when Lollipay next looks at the session, which can be a while after its hour ends. |
payment.succeeded | A payment went through. |
payment.failed | A payment attempt was declined or failed. |
payment.refunded | Part or all of a payment was refunded. Check amount_refunded. |
payment.disputed | A buyer opened a dispute. |
payment.dispute_closed | A dispute was resolved. |
webhook.test | You pressed Send test event. Only that endpoint gets it. |
An endpoint can subscribe to All events, which also covers event types added later. Test endpoints only receive test-mode events, and live endpoints only live ones.
The request
POST /webhooks/lollipay HTTP/1.1
Content-Type: application/json
User-Agent: Lollipay-Webhooks/1.0
Lollipay-Webhook-Signature: t=1792396800,v1=5f8c…
Lollipay-Event-Id: evt_8b1d…
Lollipay-Event-Type: checkout.completed
Lollipay-Delivery-Id: whd_3a90…
Lollipay-Delivery-Attempt: 1{
"id": "evt_8b1d0c6f2a5e4c1b9d7e3f60a2b4c8d1",
"object": "event",
"type": "checkout.completed",
"livemode": false,
"created": 1792396800,
"api_version": "v1",
"data": {
"object": {
"id": "cs_…",
"object": "checkout_session",
"status": "complete",
"currency": "USD",
"total_amount": 4900,
"metadata": { "order_id": "1042" },
"payment_id": "pay_…"
}
}
}data.object is the checkout session (the same fields as retrieving it) or the payment: id, status, amount, amount_refunded, fee, net, currency, method, card_last4, buyer_country, customer_name, checkout_id and created_at. Amounts are in minor units.
Verify the signature
Every request is signed with the endpoint's signing secret (whsec_…, under Signing secret in the endpoint's menu). Lollipay-Webhook-Signature holds a Unix timestamp t and v1, the hex HMAC-SHA256 of {t}.{raw body} keyed with the secret.
- Read the raw request body. Parsed and re-serialized JSON won't match.
- Compute
HMAC-SHA256(secret, t + "." + rawBody)and compare it withv1in constant time. - Reject timestamps more than 5 minutes old. Every attempt is signed afresh, so retries pass this check too.
import crypto from "node:crypto";
export function verifyLollipayWebhook(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}import hashlib, hmac, time
def verify_lollipay_webhook(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
timestamp = int(parts.get("t", "0"))
if abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Roll secret replaces the secret at once, for new events and retries alike, so deploy the new one right after rolling.
Respond, retries and duplicates
- Answer with any
2xxwithin 10 seconds. Do slow work after you respond. - Anything else counts as a failure: another status, a timeout, a connection error or a redirect (redirects aren't followed). Lollipay retries 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 24 hours later, 8 attempts in all over about two days.
- Deliveries are at least once, and the order isn't guaranteed. Store
Lollipay-Event-Id(the body'sid) and skip events you've already handled. - In the dashboard you can see every delivery's response, Resend a finished one, and Send test event. Disabling an endpoint cancels its queued deliveries.
Endpoint rules
- HTTPS on the default port, with a public hostname. IP addresses,
localhostand private names are refused, and so are hostnames that resolve to private addresses. - To test against your own machine, put it behind a tunnel such as ngrok or Cloudflare Tunnel and use the tunnel's HTTPS URL.
- Up to 16 endpoints per store. Events and deliveries are kept for 30 days.
Request logs
Every API response carries a Request-Id header (req_…). Dashboard → Developers → Logs lists each authenticated request with its status, timing, key and bodies for 30 days. Search it by that ID when you're chasing a specific call. Headers are never stored, and secret keys in bodies are replaced with [redacted].