How to receive LimaoPay webhooks
LimaoPay sends an HTTPS POST signed with HMAC SHA256 for every event (payment confirmed, subscription activated, refund). You create the endpoint in the dashboa…
Short answer
LimaoPay sends an HTTPS POST signed with HMAC SHA-256 for every event (payment confirmed, subscription activated, refund). You create the endpoint in the dashboard, receive a signing secret, and validate the signature on your server.
What it's for
Imagine you have a SaaS with Free and Pro plans, using LimaoPay to charge for Pro. When someone pays, you need to unlock Pro for that user automatically. That's exactly what webhooks are for:
- Customer pays in LimaoPay
- LimaoPay sends a
order.paidPOST to your server - Your server receives, validates the signature, unlocks Pro
No polling, no latency, no need to query the API constantly.
Step 1 — Create the endpoint
- Go to Dashboard → Developers → Webhooks
- Click New endpoint
- Pick the mode:
- Test: for development. Doesn't receive real events — only the ones you fire manually via the "Sandbox" button
- Live: for production. Receives events from real payments
- Paste the public HTTPS URL of your server (e.g.,
https://api.yoursaas.com/webhooks/limaopay) - Check the events you want to receive (at least
order.paid) - Click Create webhook
LimaoPay shows the signing secret (whsec_...). Copy it now — you won't see it again. If you lose it, you'll have to rotate.
Step 2 — Validate the signature
Each POST arrives with this header:
LimaoPay-Signature: t=1737686400,v1=<hmac_sha256_hex>
The signature is the HMAC SHA-256 of the string <timestamp>.<rawBody> using your signing secret.
Node.js example
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.LIMAOPAY_WEBHOOK_SECRET!;
// IMPORTANT: use the raw body — JSON.parse before will invalidate the signature
app.post(
"/webhooks/limaopay",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body.toString("utf8");
const sig = req.headers["limaopay-signature"] as string;
if (!verify(rawBody, sig)) return res.status(401).send("invalid");
const event = JSON.parse(rawBody);
if (event.type === "order.paid") {
// Grant access using buyer_email or external_customer_id
console.log("Paid:", event.data.object.buyer_email);
}
res.status(200).send("ok");
},
);
function verify(body: string, sigHeader: string): boolean {
const parts = Object.fromEntries(
sigHeader.split(",").map((p) => p.split("=")),
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) return false;
// Anti-replay: reject timestamps outside 5min window
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", SECRET)
.update(`${t}.${body}`)
.digest("hex");
if (v1.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex"));
}
Why validate this way?
- HMAC: ensures the POST came from LimaoPay (only someone with the secret can generate the signature)
- Timing-safe compare: prevents an attacker from discovering the secret by measuring comparison time
- Timestamp tolerance: prevents replay attacks (someone capturing an old POST and re-sending it)
Payload structure
Stripe Event Object style — the type field discriminates, and data.object changes shape depending on the event. Example of an order.paid:
{
"id": "evt_2k4m9x1abc",
"type": "order.paid",
"created": 1737686400,
"livemode": true,
"api_version": "2026-01-01",
"data": {
"object": {
"id": "ord_8f3a1b2c",
"object": "order",
"tenant_id": "tnt_seller_xyz",
"offer_id": "off_pro_plan",
"product_id": "prod_pro",
"status": "paid",
"amount_total": 4900,
"currency": "brl",
"payment_method": "pix",
"buyer_email": "buyer@example.com",
"buyer_name": "Maria Silva",
"external_customer_id": "user_42",
"external_metadata": { "plan": "pro" },
"product_subscription_id": null,
"paid_at": "2026-05-21T14:30:00.000Z",
"refunded_at": null
}
}
}
Where to find what you need:
| I want to know… | Path in the payload |
|---|---|
| Buyer's email | data.object.buyer_email |
| Buyer's name | data.object.buyer_name |
| Your internal user ID | data.object.external_customer_id |
| Metadata you passed in the URL | data.object.external_metadata |
| Amount paid (in cents) | data.object.amount_total |
| Currency | data.object.currency (lowercase: brl, usd) |
| Payment method | data.object.payment_method (pix, stripe, mp) |
| When it was paid | data.object.paid_at (ISO 8601) |
| Unique event ID (for dedupe) | header LimaoPay-Event-Id or id field |
Subscription events (subscription.activated, subscription.renewed, etc.) have a data.object with a different shape — they include billing_cycle, current_period_start/end, price_at_creation, and also buyer_email.
Step 3 — Idempotency
LimaoPay may send the same event twice (in case of retry). Use the LimaoPay-Event-Id header to dedupe:
const eventId = req.headers["limaopay-event-id"] as string;
if (await alreadyProcessed(eventId)) {
return res.status(200).send("ok (duplicate)");
}
// ... process
await markProcessed(eventId);
Step 4 — Test before going to production
In the dashboard, inside a test mode endpoint, there's a Sandbox button:
- Pick the event type (e.g.,
order.paid) - Click Fire mock event
- LimaoPay sends a POST with realistic payload to your server
- You see the result under "Recent deliveries" — including response status and timing
Use this to validate the integration end-to-end before creating a live endpoint.
Step 5 — Match the payment to your SaaS user
When your SaaS customer is about to pay, generate the LimaoPay page link with their ID attached:
https://limaopay.app/yourstore/product?external_customer_id=user_42&metadata[plan]=pro
URL structure:
| Part | Meaning |
|---|---|
limaopay.app | Fixed domain (or limaopay.com.br in pt-BR) |
yourstore | Seller's store slug (chosen at signup) |
product | Product page slug |
?external_customer_id=user_42 | The user ID in YOUR system (returned at data.object.external_customer_id) |
&metadata[key]=value | Free metadata — multiple keys allowed (returned at data.object.external_metadata) |
How it works under the hood: the page stores those values in sessionStorage and sends them to the backend when the customer clicks pay. The backend persists to Order.externalCustomerId + Order.externalMetadata. The webhook delivers both in the payload.
Limits (aligned with Stripe Metadata):
- Up to 50 metadata keys
- Key: up to 40 chars, value: up to 500 chars
external_customer_id: up to 255 chars
Works across all payment methods — Stripe, PIX (V1 and V2 inline), and Mercado Pago.
Step 6 — Post-payment redirect (optional)
By default, after the buyer pays, LimaoPay shows a "Payment confirmed" modal and stays there. For external SaaS apps that want to bring the buyer back to their own product (custom thank-you page, instant access provisioning, etc.), configure two URLs on the webhook:
- Go to Dashboard → Developers → Webhooks → [your endpoint]
- Fill in Success URL (e.g.,
https://yoursaas.com/limaopay/success) and optionally Cancel URL
Once set, every confirmed payment for that seller displays the confirmation modal for 2.5 seconds with a spinner ("Redirecting you back…") and then sends the buyer to your success URL.
How to match the returning Order
The success URL is fixed (doesn't change per checkout). To know which Order the buyer just completed, use:
- The
order.paidwebhook (delivered alongsideLimaoPay-Event-Id): has all the data —order.id,buyer_email,external_customer_id,external_metadata - If you need to identify at redirect time (before the webhook lands), pass your internal ID as
external_customer_idon the LimaoPay page URL:?external_customer_id=user_42. Persist that ID server-side as "awaiting payment" and reconcile when the webhook arrives
Security validation
- HTTPS required (HTTP allowed only in dev)
- Hostnames like
localhost,.local,.internal, and private IPs are blocked (anti-SSRF defense) - 2048-char max per URL
- The URL is snapshotted on the Order at creation time. Later changes to the webhook config don't affect in-flight orders
When the redirect does NOT happen
- Webhook in test mode — only live endpoints participate
- Webhook paused (
status: pausedordisabled_by_failures) - Buyer closed the tab during polling — no browser left to redirect
- Your URL failed HTTPS validation — the seller gets an error on create/update and the redirect isn't configured
Retry policy
If your server responds with anything other than 2xx, LimaoPay retries:
| Attempt | Delay |
|---|---|
| 1 | immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 8 hours |
| 7 | 24 hours |
| 8 | 48 hours |
After that, the event is marked exhausted. After 30 consecutive failures, the endpoint is auto-paused and you receive an email.
Rotating the secret
If you suspect a leak, rotate it in the dashboard. The old secret stays valid for 24h (window to update your receiver). During the window, LimaoPay sends 2 signatures: v1 (new) and v1_prev (old).
Checklist
- Endpoint created with public HTTPS URL
- Signing secret copied and saved as env var
- HMAC validation with timing-safe compare
- Timestamp validation (anti-replay, 5min tolerance)
- Idempotency using
LimaoPay-Event-Id - Tested in Sandbox mode before creating a live endpoint
- Endpoint responds 2xx within 10 seconds
- Doesn't follow redirects (return final 2xx, not 3xx)