lollipayDocs

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

EventSent when
checkout.createdA checkout session was created.
checkout.completedA buyer paid a checkout session. This is the one to fulfil orders on.
checkout.expiredA 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.succeededA payment went through.
payment.failedA payment attempt was declined or failed.
payment.refundedPart or all of a payment was refunded. Check amount_refunded.
payment.disputedA buyer opened a dispute.
payment.dispute_closedA dispute was resolved.
webhook.testYou 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.

  1. Read the raw request body. Parsed and re-serialized JSON won't match.
  2. Compute HMAC-SHA256(secret, t + "." + rawBody) and compare it with v1 in constant time.
  3. Reject timestamps more than 5 minutes old. Every attempt is signed afresh, so retries pass this check too.
Node.js
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);
}
Python
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 2xx within 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's id) 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, localhost and 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].

On this page