Voltar pro blog
integracoes

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…

Equipe LimãoPayOctober 20, 20188 min read

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:

  1. Customer pays in LimaoPay
  2. LimaoPay sends a order.paid POST to your server
  3. Your server receives, validates the signature, unlocks Pro

No polling, no latency, no need to query the API constantly.

Step 1 — Create the endpoint

  1. Go to Dashboard → Developers → Webhooks
  2. Click New endpoint
  3. 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
  4. Paste the public HTTPS URL of your server (e.g., https://api.yoursaas.com/webhooks/limaopay)
  5. Check the events you want to receive (at least order.paid)
  6. 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 emaildata.object.buyer_email
Buyer's namedata.object.buyer_name
Your internal user IDdata.object.external_customer_id
Metadata you passed in the URLdata.object.external_metadata
Amount paid (in cents)data.object.amount_total
Currencydata.object.currency (lowercase: brl, usd)
Payment methoddata.object.payment_method (pix, stripe, mp)
When it was paiddata.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:

  1. Pick the event type (e.g., order.paid)
  2. Click Fire mock event
  3. LimaoPay sends a POST with realistic payload to your server
  4. 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:

PartMeaning
limaopay.appFixed domain (or limaopay.com.br in pt-BR)
yourstoreSeller's store slug (chosen at signup)
productProduct page slug
?external_customer_id=user_42The user ID in YOUR system (returned at data.object.external_customer_id)
&metadata[key]=valueFree 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:

  1. Go to Dashboard → Developers → Webhooks → [your endpoint]
  2. 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.paid webhook (delivered alongside LimaoPay-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_id on 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: paused or disabled_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:

AttemptDelay
1immediate
21 minute
35 minutes
430 minutes
52 hours
68 hours
724 hours
848 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)

Comece a vender hoje

Crie sua página de vendas com IA, configure o pagamento e comece a faturar. Zero pra começar.