Webhooks let your application react to events as they happen — a payment succeeds, a refund is created, a customer is updated. Instead of polling the API, you register a URL and we send you an HTTP POST with the event data.
Setting up a webhook endpoint
Create an endpoint by specifying a URL and the event types you want to receive:
curl -X POST https://api.leanrails.com/v1/webhook_endpoints \
-u "$API_KEY:" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks",
"enabled_events": [
"payment_intent.succeeded",
"payment_intent.payment_failed",
"charge.refunded"
]
}'
The webhook secret (whsec_...) is only returned once — when you create the endpoint. Store it securely. You’ll need it to verify webhook signatures.
Verifying signatures
Every webhook delivery includes an X-Signature header. Always verify this signature before processing the event to ensure it came from us and hasn’t been tampered with.
The signature header format is:
X-Signature: t=1709913600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
The signature is computed as:
HMAC-SHA256(webhook_secret, "v1=" + timestamp + "." + raw_request_body)
const crypto = require("crypto");
function verifyWebhookSignature(payload, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((item) => item.split("=", 2))
);
const timestamp = parts.t;
const expectedSig = parts.v1;
// Reject timestamps older than 5 minutes to prevent replay attacks
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
throw new Error("Timestamp too old");
}
const signedPayload = `v1=${timestamp}.${payload}`;
const computedSig = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
if (
!crypto.timingSafeEqual(
Buffer.from(computedSig),
Buffer.from(expectedSig)
)
) {
throw new Error("Invalid signature");
}
return JSON.parse(payload);
}
// Express.js example
app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const sig = req.headers["x-signature"];
const event = verifyWebhookSignature(req.body.toString(), sig, WEBHOOK_SECRET);
switch (event.type) {
case "payment_intent.succeeded":
console.log("Payment succeeded:", event.data.object.id);
break;
case "payment_intent.payment_failed":
console.log("Payment failed:", event.data.object.id);
break;
}
res.status(200).json({ received: true });
});
Retry schedule
If your endpoint returns a non-2xx response or times out, we retry the delivery with increasing delays:
| Attempt | Delay | Cumulative Time |
|---|
| 1 | Immediate | 0 |
| 2 | 1 minute | 1 minute |
| 3 | 5 minutes | 6 minutes |
| 4 | 30 minutes | 36 minutes |
| 5 | 2 hours | ~2.5 hours |
| 6 | 8 hours | ~10.5 hours |
| 7 | 24 hours | ~1.5 days |
| 8 | 72 hours | ~4.5 days |
After 8 failed attempts, the delivery is marked as exhausted and no further retries are attempted.
Endpoint requirements
- Must accept POST requests with
Content-Type: application/json
- Must return a 2xx status code within 30 seconds
- Must verify the
X-Signature header before processing
Best practices
Respond quickly. Return a 200 response immediately, then process the event asynchronously. If your handler takes longer than 30 seconds, the delivery will be marked as failed and retried.
- Verify signatures on every request to prevent spoofing
- Deduplicate by event ID — the same event may be delivered more than once during retries
- Process asynchronously — use a message queue to handle events outside the request cycle
- Subscribe selectively — only subscribe to event types you need to reduce load
- Handle test events — in test mode, events include
"livemode": false
Event types
See Event Types for the complete list of events you can subscribe to.