LealUp Docs
Integrations

Ingestion API

Send product usage events and billing events to LealUp from your own system, authenticated with an API key.

If your product is not on the integrations list, or you want to send your own events, the Ingestion API is the direct route: your backend POSTs, and the events land in LealUp exactly like those from any connector.

It is LealUp's only public API today. It is built for machines, not browsers: the key is used from your server.

Before you start

  • Base URL: https://api.lealup.com/v1
  • Authentication: X-API-Key header
  • Format: JSON in the request and in the response

Getting your API key

  1. Go to Settings → API key.
  2. Generate the key. It has the format sk_live_ followed by 64 hexadecimal characters.
  3. Copy it right then. Afterwards you only see the last 4 characters; the rest is masked and there is no way to recover it.
  4. If you lose it or it leaks, regenerate it from the same screen. The previous one stops working immediately.

The key identifies your organization: LealUp derives the tenant from the key, never from the request body. Store it like any other production secret and never ship it in client-side code.

An sk_live_ key grants write access to your organization's events. Treat it as a server credential: environment variable or secret manager, never in a repository or in browser JavaScript.

Sending usage events

POST /v1/ingest/events

This is the main endpoint: it records what your users do in your product. Those events feed adoption, the health score and playbook triggers.

Request body

FieldTypeRequiredDetail
eventsarrayyesBetween 1 and 100 events per batch

And inside each event:

FieldTypeRequiredDetail
event_namestringyesEvent name, 1 to 255 characters
customer_idstringsee noteThe customer's LealUp UUID
external_customer_idstringsee noteThe identifier that customer has in your system
external_sourcestringnoSource system for external_customer_id. Defaults to internal
user_idstringnoIdentifier of the user who produced the event
product_idUUIDnoAttribution to a product in your catalog
product_codestringnoSame as product_id, but by code or SKU
propertiesobjectnoFree-form JSON properties
timestampISO 8601 datenoWhen it happened. Defaults to ingestion time

About the customer: every event needs a customer reference, and either one works. If you send customer_id, that one is used. If you send only external_customer_id, LealUp resolves it from the combination of your organization, external_source and that identifier; it has to match the value the customer carried when it was created in LealUp. If you send both, customer_id wins.

About the product: if you send product_id and product_code, product_id wins. An event with neither is an account-level event, not a product one, and behaves as it always has. A product that does not exist in your catalog does not reject the event: it is stored as an account-level event.

Example

curl -X POST https://api.lealup.com/v1/ingest/events \
  -H "X-API-Key: $LEALUP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      {
        "event_name": "report_exported",
        "external_customer_id": "acme_12345",
        "external_source": "internal",
        "user_id": "u_889",
        "properties": { "format": "pdf", "rows": 1240 },
        "timestamp": "2026-08-05T14:32:00Z"
      }
    ]
  }'

Response

{
  "accepted": 1,
  "rejected": 0,
  "errors": [],
  "message": "Accepted 1 events for processing"
}

The status code is 202 Accepted.

Partially accepted batches

This is the most important thing about this endpoint: one bad event does not sink the batch. The response is still 202 and tells you which ones did not make it:

{
  "accepted": 2,
  "rejected": 1,
  "errors": [
    {
      "index": 1,
      "event_name": "report_exported",
      "code": "unknown_external_customer",
      "message": "..."
    }
  ],
  "message": "Accepted 2 events; 1 rejected"
}

index is the position of the event inside the array you sent, starting at 0. The possible codes:

CodeWhat it means
unknown_customerYou sent customer_id, but no customer with that UUID exists in your organization
unknown_external_customerThe combination of external_source and external_customer_id did not resolve to any customer
invalid_customer_idcustomer_id is not a valid UUID

Always check rejected. A 202 does not mean everything landed.

Sending billing events

POST /v1/ingest/billing-events

Records collection facts (failed payments, disputes, retries) that feed the customer's payment health.

Request body

FieldTypeRequiredDetail
eventsarrayyesBetween 1 and 50 events per batch

And inside each event:

FieldTypeRequiredDetail
event_typestringyesOne of: payment_failed, payment_succeeded, invoice_disputed, dunning_attempt, refund_issued
customer_idUUIDyesThe customer's LealUp UUID. The external identifier does not work here
amountnumbernoAmount, zero or positive
currencystringno3-letter ISO 4217 code. Defaults to USD
statusstringnoOne of: pending, resolved, escalated. Defaults to pending

Example

curl -X POST https://api.lealup.com/v1/ingest/billing-events \
  -H "X-API-Key: $LEALUP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      {
        "event_type": "payment_failed",
        "customer_id": "6f1c2e40-9b3a-4a11-8f0e-2a7c5d9e1b44",
        "amount": 249.90,
        "currency": "USD",
        "status": "pending"
      }
    ]
  }'

Response

{
  "accepted": 1,
  "skipped": 0,
  "message": "Accepted 1 billing events (0 skipped)"
}

Also 202 Accepted. skipped counts events the database rejected for colliding with one already on record.

This endpoint does not deduplicate: send each event exactly once. The collision skipped reports is decided, among other fields, by the exact instant the row was written, so two sends of the same event land as two distinct events. In practice skipped always comes back 0.

It matters because these events feed the customer's payment health: retrying a batch that half-failed would inflate their failed payments. If your process retries, track on your side what you already sent.

Limits

LimitValue
Requests per minute300 per organization
Usage events per batch100
Billing events per batch50

The 300 per minute limit is shared across both endpoints and counted per organization, not per key and not per IP. That is the default: if your volume justifies it, it can be raised for your organization.

For high volume, batch: 100 events in one request cost the same as 1.

Errors

Every response, successful or not, includes the X-Trace-Id header. Keep it: it is the first thing support will ask for.

401, something wrong with the key

{ "detail": "Missing X-API-Key header" }

You get this when the X-API-Key header is missing or the key is invalid (the message becomes Invalid API key). The most common cause is regenerating the key and forgetting to update the environment variable.

422, the body fails validation

{
  "detail": [
    {
      "type": "too_long",
      "loc": ["body", "events"],
      "msg": "List should have at most 100 items after validation, not 101",
      "input": []
    }
  ]
}

loc tells you exactly where the problem is: ["body", "events", 0, "event_type"] means the event_type field of the first event in the batch.

Mind the difference: a batch of 101 events, or an event_type outside the allowed list, are validation errors and fail the whole batch with 422. A customer that does not exist is a per-event error and returns 202 with the detail in errors.

429, you went over the limit

{
  "detail": {
    "type": "https://lealup.com/errors/rate-limited",
    "title": "Rate limit exceeded",
    "status": 429,
    "detail": "Rate limit exceeded for tier 'ingestion'. Try again in 37 seconds.",
    "retry_after": 37
  }
}

The response carries a Retry-After header with the remaining seconds. Honour it instead of retrying immediately.

500, something broke on our side

{
  "type": "https://lealup.local/errors/500",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "An unexpected error occurred"
}

No event in the batch was stored: the write is atomic per request. Retry with backoff and, if it persists, write to us with the X-Trace-Id.

Integrating well

  • Send in batches, not event by event. Buffer and flush every few seconds or every 100 events, whichever comes first.
  • Retry only 429 and 5xx, with backoff. A 422 does not improve on retry: the body is wrong and has to be fixed.
  • Retrying resends. If a request dies on timeout and you cannot tell whether it landed, a retry may record the events twice. For adoption metrics that is usually tolerable; if you need exactness, keep track of which batches you confirmed.
  • Do not block your user. Send events from a queue in your backend, not inside the request serving a person.
  • Start with external_customer_id. It saves you from maintaining a mapping table against LealUp UUIDs.
  • Check rejected and log the errors. That is where you will see that a new customer does not exist in LealUp yet.

FAQ

Can I call the API from the browser?

No. The key grants write access to your whole organization's events and in the browser it is exposed. Always call from your server.

What happens if I send an event for a customer that does not exist in LealUp yet?

That event is rejected with unknown_customer or unknown_external_customer, and the rest of the batch lands normally. Create the customer first (manually, by CSV or through a CRM integration) and resend.

Can I delete an event I already sent?

Not through the API. Write to [email protected] if you need to correct data.

Is there an official client library?

Not yet. It is two endpoints with JSON: any HTTP client works.

On this page