LimePay API v1 (read-only)

Base URL: https://limepay.app/api/v1

Auth

Authenticate every request with a Bearer token generated in Settings → API keys:

Authorization: Bearer lpk_live_abcd1234ef567890.deadbeef…(32 hex chars)

Tokens come in two modes:

  • lpk_live_… — acts on production data
  • lpk_test_… — reserved for sandbox (not yet wired to a separate dataset; same DB, mode flag preserved for forward compat)

Revoke keys you no longer need from the same settings page — revocation is immediate.

Errors

JSON-encoded:

{ "error": { "code": "unauthorized", "message": "Invalid API key" } }
HTTPcodemeaning
401unauthorizedMissing / malformed / revoked / invalid key
404not_foundThe resource doesn't exist within your vendor scope
400variousBad request / validation failure
500server_errorDB or upstream issue

Vendor scope is enforced server-side from the API key. You can never see another vendor's data.

Pagination

List endpoints accept:

  • limit — default 50, max 100
  • offset — default 0

Responses include:

{
  "data": [ … ],
  "pagination": {
    "total": 137,
    "limit": 50,
    "offset": 0,
    "has_more": true
  }
}

Endpoints

GET /v1/subscriptions

List subscriptions belonging to the authed vendor. Returns the row plus FK references to plan and subscriber (so you can fetch them in follow-up calls if needed).

Query params

  • statusactive | pending | paused | past_due | cancelled
  • subscriber_id — UUID; only subs for a specific buyer
  • plan_id — UUID; only subs for a specific plan
  • limit / offset — see pagination

Example

curl https://limepay.app/api/v1/subscriptions?status=active \
  -H "Authorization: Bearer lpk_live_…"

Response (truncated)

{
  "data": [
    {
      "id": "30268041-69e2-4862-9f9f-276ce1fac517",
      "status": "active",
      "plan_id": "0610f789-aea6-4c61-8c88-9859d11392d1",
      "subscriber_id": "076c4fbc-65ff-45d3-b47f-258f108e5d47",
      "mp_preapproval_id": "5cef672e85cc466ea6d2eceb3e75ccec",
      "started_at": "2026-05-12T20:24:21.939Z",
      "current_period_start": "2026-05-12T20:24:21.939Z",
      "current_period_end": "2026-06-12T20:24:22Z",
      "cancel_at_period_end": false,
      "cancelled_at": null,
      "created_at": "2026-05-12T20:41:13.265Z"
    }
  ],
  "pagination": { "total": 1, "limit": 50, "offset": 0, "has_more": false }
}

GET /v1/subscriptions/{id}

Fetch a single subscription. Returns 404 if the id doesn't belong to your vendor (we don't distinguish "doesn't exist" from "doesn't belong to you" to avoid leaking existence).

Example

curl https://limepay.app/api/v1/subscriptions/30268041-69e2-4862-9f9f-276ce1fac517 \
  -H "Authorization: Bearer lpk_live_…"

POST /v1/subscriptions/{id}/cancel

Cancel a subscription. Idempotent — calling it on an already-cancelled subscription returns 200 with the current state, not an error.

Cancellation propagates to Mercado Pago (PUT /preapproval/{id} with status: "cancelled"), updates the local row to status: "cancelled" with cancelled_at set, and fans out a subscription.cancelled webhook to your registered endpoints (via the inbound MP reconciliation webhook — usually within a few seconds).

Body (optional, JSON):

FieldTypeNotes
reasonstring, ≤ 500 charsFree text. Surfaced in the dashboard's churn view.

Errors

StatusCodeWhen
404not_foundSubscription not found for this vendor.
409invalid_stateSubscription isn't linked to MP yet (still pending).
502upstream_errorMercado Pago rejected the cancellation.

Example

curl -X POST https://limepay.app/api/v1/subscriptions/30268041-69e2-4862-9f9f-276ce1fac517/cancel \
  -H "Authorization: Bearer lpk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"reason": "customer requested via support ticket #4821"}'

Returns the updated subscription:

{
  "data": {
    "id": "30268041-69e2-4862-9f9f-276ce1fac517",
    "status": "cancelled",
    "cancelled_at": "2026-05-13T18:42:11.000Z",
    "..."
  }
}

GET /v1/subscribers

List subscribers belonging to the vendor.

Query params

  • email — exact match; typical use case: "is this customer in LimePay?"
  • limit / offset — see pagination

Example

curl "https://limepay.app/api/v1/subscribers?email=cafetero@gmail.com" \
  -H "Authorization: Bearer lpk_live_…"

GET /v1/subscribers/{id}

Fetch a single subscriber.

curl https://limepay.app/api/v1/subscribers/076c4fbc-65ff-45d3-b47f-258f108e5d47 \
  -H "Authorization: Bearer lpk_live_…"

Typical fulfillment flow

# 1. On dispatch day, get all active subscribers
curl "https://limepay.app/api/v1/subscriptions?status=active&limit=100" \
  -H "Authorization: Bearer $LIMEPAY_KEY" \
  | jq -r '.data[].subscriber_id' \
  | while read sub_id; do
      curl -s "https://limepay.app/api/v1/subscribers/$sub_id" \
        -H "Authorization: Bearer $LIMEPAY_KEY" \
        | jq '{email, name}'
    done


Outbound webhooks

Configure under Settings → Webhooks in the dashboard. We POST a signed JSON body to your URL whenever a lifecycle event happens on your vendor.

Delivery

POST https://your-app.com/webhooks/limepay
Content-Type: application/json
User-Agent: LimePay-Webhooks/1
X-LimePay-Event-Id: evt_…
X-LimePay-Event-Type: subscription.cancelled
X-LimePay-Signature: t=1715625600,v1=<sha256-hex>

{
  "id": "evt_…",
  "type": "subscription.cancelled",
  "api_version": "2026-05-13",
  "created": 1715625600,
  "data": {
    "subscription_id": "…",
    "mp_preapproval_id": "…",
    "status": "cancelled",
    "previous_status": "active",
    "plan_id": "…",
    "subscriber_id": "…"
  }
}

Verifying the signature

v1 = HMAC_SHA256(secret, "<t>.<rawBody>") where secret is the value we showed you once at creation time (prefixed whsec_…).

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((p) => {
      const eq = p.indexOf("=");
      return [p.slice(0, eq), p.slice(eq + 1)];
    }),
  );
  const t = Number(parts.t);
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false; // 5-min replay window
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const got = Buffer.from(parts.v1 ?? "", "hex");
  return expected.length === got.length && timingSafeEqual(expected, got);
}

Replay protection: reject if the t= timestamp is more than 5 minutes off (we send the current time).

Retries and reliability

  • 10-second request timeout.
  • Any non-2xx response (or network failure) is retried by Inngest on 1m / 5m / 30m / 2h / 6h backoff (5 retries total).
  • 5 consecutive failures on the same endpoint → we auto-disable the webhook and surface it in the dashboard so the vendor can fix and reactivate.
  • Every attempt is logged in webhook_deliveries and visible in Settings → Webhooks → Últimas entregas.

Idempotency

The X-LimePay-Event-Id header is stable for a given logical event. If we retry, we re-send it. Dedupe on your side by storing seen event ids — your processing should be idempotent.

Events

EventWhen
subscription.activatedNew sub or trial just turned active
subscription.cancelledBuyer or vendor cancelled
subscription.pausedSub set to paused
subscription.past_dueMP flagged a recurring payment as past due
subscription.recoveredCame back to active from paused/past_due
subscription.renewed(reserved — fired on each successful renewal cobro, soon)
payment.succeededMP approved a charge for > $0
payment.failedMP rejected a charge
invoice.issuedARCA issued the fiscal invoice with CAE

Payload shapes

subscription.*:

{
  "subscription_id": "uuid",
  "mp_preapproval_id": "string",
  "status": "active|cancelled|paused|past_due",
  "previous_status": "active|cancelled|paused|past_due|null",
  "plan_id": "uuid",
  "subscriber_id": "uuid",
  "started_at": "ISO-8601 | null",
  "current_period_end": "ISO-8601 | null"
}

payment.succeeded / payment.failed:

{
  "transaction_id": "uuid",
  "mp_payment_id": "string",
  "subscription_id": "uuid | null",
  "amount": 10000,
  "currency": "ARS",
  "paid_at": "ISO-8601 | null",
  "reason": "string | null"  // only on payment.failed
}

invoice.issued:

{
  "invoice_id": "uuid",
  "transaction_id": "uuid",
  "subscriber_id": "uuid",
  "tipo_comprobante": 11,
  "punto_venta": 2,
  "numero": 5,
  "cae": "86195303445754",
  "total": 10000
}

Implementing access control

If your product gates content/features behind an active subscription, the rule is simple: grant access while status === "active", revoke it otherwise. LimePay sends you every state transition as a webhook, so you don't have to poll.

The four events that matter for gating:

EventWhat it meansWhat to do
subscription.activatedNew sub paid + activeGrant access
subscription.past_dueLast cobro failed (incl. first cobro) — MP will retryRevoke access immediately if you don't want to extend credit; or keep it open until cancelled if you're more lenient
subscription.recoveredSub came back to active (buyer fixed the card / MP retry succeeded)Restore access
subscription.cancelledSub closed for goodRevoke access permanently

Why past_due fires on a first failed cobro

MP marks the preapproval authorized as soon as the card validates, so subscriptions land as active before any money has actually cleared. If the very first charge attempt is rejected (insufficient funds, declined, etc.), LimePay demotes the sub to past_due and fires subscription.past_due — even though no successful cobro ever happened. Treat it identically to a later failure: the buyer hasn't paid.

Recommended handler

def handle_limepay_webhook(event_type, payload):
    sub_id = payload["subscription_id"]
    subscriber_id = payload["subscriber_id"]
    user = User.objects.get(limepay_subscriber_id=subscriber_id)

    if event_type == "subscription.activated":
        user.is_paid_member = True
        user.save()

    elif event_type in ("subscription.past_due", "subscription.cancelled"):
        # No grace period: cut access on the first rejected cobro.
        user.is_paid_member = False
        user.save()

    elif event_type == "subscription.recovered":
        user.is_paid_member = True
        user.save()

You decide the policy. Some vendors give a grace window between past_due and cancelled (MP retries for ~10 days before giving up); others revoke immediately on past_due because they don't want to extend service to non-payers. Both are fine — LimePay just delivers the signals.

Mapping subscriber → user

Link your user to the LimePay subscriber once, on subscription.activated. The payload has subscriber_id (LimePay's id) and you can fetch the buyer's email/document via GET /v1/subscribers/{id} if you don't have it yet. From then on, every webhook for that sub references the same subscriber_id.

If you set external_id on the subscriber at checkout time (via the buyer-facing form), it round-trips on every subscriber payload — easier than maintaining a join table.

Don't poll

You technically can hit GET /v1/subscriptions/{id} and read status, but you'll be late: webhooks fire within seconds of the MP event landing on our side, while polling adds whatever interval you chose. Use the API for backfills / reconciliation, not for primary signal.


Roadmap

  • More write endpoints (pause/resume, refund payment, etc.) — when there's demand from a real vendor.
  • GET /v1/invoices — same scope rules, returns issued ARCA invoices with CAE + PDF URL.
  • GET /v1/plans + POST /v1/plans — already covered by the dashboard for now.
  • subscription.renewed event — fires on each successful renewal cobro (currently payment.succeeded covers this case).