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-Keyheader - Format: JSON in the request and in the response
Getting your API key
- Go to Settings → API key.
- Generate the key. It has the format
sk_live_followed by 64 hexadecimal characters. - Copy it right then. Afterwards you only see the last 4 characters; the rest is masked and there is no way to recover it.
- 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
| Field | Type | Required | Detail |
|---|---|---|---|
events | array | yes | Between 1 and 100 events per batch |
And inside each event:
| Field | Type | Required | Detail |
|---|---|---|---|
event_name | string | yes | Event name, 1 to 255 characters |
customer_id | string | see note | The customer's LealUp UUID |
external_customer_id | string | see note | The identifier that customer has in your system |
external_source | string | no | Source system for external_customer_id. Defaults to internal |
user_id | string | no | Identifier of the user who produced the event |
product_id | UUID | no | Attribution to a product in your catalog |
product_code | string | no | Same as product_id, but by code or SKU |
properties | object | no | Free-form JSON properties |
timestamp | ISO 8601 date | no | When 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:
| Code | What it means |
|---|---|
unknown_customer | You sent customer_id, but no customer with that UUID exists in your organization |
unknown_external_customer | The combination of external_source and external_customer_id did not resolve to any customer |
invalid_customer_id | customer_id is not a valid UUID |
Always check rejected. A 202 does not mean everything landed.
If you send an Idempotency-Key and get back rejected > 0, fix and resend ONLY the corrected items — under a NEW key. The response you got is cached under the key you sent, for 24 hours (see "Retries and Idempotency-Key" below). Resending the SAME batch — even after fixing the underlying data — under the SAME key replays that ORIGINAL response verbatim, rejections included: nothing new is written, and the event you meant to fix never lands. A NEW key is what makes the corrected send actually run.
Sending billing events
POST /v1/ingest/billing-events
Records collection facts (failed payments, disputes, retries) that feed the customer's payment health.
Request body
| Field | Type | Required | Detail |
|---|---|---|---|
events | array | yes | Between 1 and 50 events per batch |
And inside each event:
| Field | Type | Required | Detail |
|---|---|---|---|
event_type | string | yes | One of: payment_failed, payment_succeeded, invoice_disputed, dunning_attempt, refund_issued |
customer_id | UUID | yes | The customer's LealUp UUID. The external identifier does not work here |
amount | number | no | Amount, zero or positive |
currency | string | no | 3-letter ISO 4217 code. Defaults to USD |
status | string | no | One of: pending, resolved, escalated. Defaults to pending |
product_id | UUID | no | Attribution to a product in your catalog |
product_code | string | no | Same as product_id, but by code or SKU |
A note on the product: if you send both product_id and product_code, product_id wins. An event with neither is an account-level event and behaves exactly as before. A product that does not exist in your catalog does not reject the event: it is stored at account level and the response says so in warnings, with the event's index within the batch. Check warnings the same way you check rejected: the batch still returns 202 even when the attribution was lost.
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,
"rejected": 0,
"skipped": 0,
"warnings": [],
"errors": [],
"message": "Accepted 1 billing events (0 skipped)"
}Also 202 Accepted. skipped counts events the database rejected for colliding with one already on record.
A customer_id that does not exist in your organization is a per-event error, exactly as on the usage-events endpoint: the response is still 202, counts it in rejected and names it in errors with the code unknown_customer. It is neither a 500 nor a rejection of the whole batch.
This endpoint does not deduplicate by content: 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 — with no shared Idempotency-Key — land as two distinct events. In practice skipped always comes back 0.
Retrying the exact same batch IS safe if you send an Idempotency-Key (see "Retries" below): the retry replays the original response instead of writing the rows again. What it does not do is spot two DIFFERENT requests that happen to describe the same billing event — track on your side what you already sent if your process resends without reusing the key.
Sending leads (web-to-lead)
POST /v1/ingest/leads
This is the entry door of the sales pipeline: your site's contact or demo form POSTs here (from your backend, with the same X-API-Key — the key never goes in the browser) and LealUp creates the account, the contact, an opportunity at the first stage of the sales pipeline, and a note with the message.
Request body
| Field | Type | Required | Detail |
|---|---|---|---|
company | string | yes | Company name, 1 to 255 characters |
email | string | yes | Contact email — the dedupe key |
contact_name | string | no | Name of the contact person |
phone | string | no | Contact phone number |
message | string | no | Free-form message from the form, up to 5000 characters |
reason | string | no | Contact reason as your own form captures it (e.g. a select you built yourself) |
source_tag | string | no | Where the form lives (e.g. landing-pricing) — recorded in the note |
product_id | UUID | no | Catalog product the lead is interested in. Wins over product_name when both are sent |
product_name | string | no | Catalog product by name instead of UUID. Resolved by exact match against your catalog |
Example
curl -X POST https://api.lealup.com/v1/ingest/leads \
-H "X-API-Key: $LEALUP_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 4b1e9f2a-6c3d-4e8a-9f21-7a5c3e9d1b44" \
-d '{
"company": "Acme Inc.",
"email": "[email protected]",
"contact_name": "Maria Perez",
"message": "I want a product demo",
"source_tag": "landing-pricing",
"product_name": "Customer Success Platform"
}'Response
{
"customer_id": "6f1c2e40-9b3a-4a11-8f0e-2a7c5d9e1b44",
"contact_id": "8a2d1f30-7c4b-4e22-9a11-3b6c5d8e1f22",
"opportunity_id": "1e9f4a20-5b3c-4d11-8e0a-2c7d5e9f1a44",
"matched_by": null,
"warnings": [],
"deduped": false,
"message": "Lead registrado"
}The status code is 201 Created.
matched_by:"email"when the email already existed as a contact in your organization and that account was reused;nullwhen a brand-new account was created.deduped: equivalent tomatched_by !== null— kept for backward compatibility.opportunity_id:nullwhen there is no active sales pipeline, no resolvable owner, no resolvable product, or the account already had an open opportunity for that same product (dedupe is by account × product, not account alone). The exact reason lands inwarnings— never a silentnull.warnings: developer-facing (English) notes about anything that degraded on this request. Aproduct_namethat does not match your catalog exactly, for example, still lands the account, contact and note — just without opening an opportunity. That is honest degradation, not an error. A 201 with an emptywarningslist is a fully-resolved lead. Common codes:
| Code | What it means |
|---|---|
opportunity_skipped:missing_product | You did not send product_id/product_name, or neither resolved — the account and contact still land |
opportunity_skipped:missing_owner | No configured inbound-lead owner and no active admin in the tenant |
no_active_sales_pipeline | The tenant has no active sales pipeline |
product_id_not_found:<uuid> / product_name_not_found:<name> | The product you sent does not exist in the tenant's catalog |
product_id_inactive:<uuid> / product_name_inactive:<name> | The product exists but has been deactivated |
possible_duplicate_account:<uuid> | The company name closely resembles an account that already exists. A new account is still created, flagged for manual review — never auto-merged |
replayed_from_ledger | This request is a retry of a lead that already fully landed, recognized through a separate, longer-lived record kept for 90 days — see "Retries past 24 hours" below. The customer_id/contact_id/opportunity_id in the response are the ORIGINAL ones; nothing new was created |
(Non-exhaustive — any new code follows the same shape: category or category:id.)
Retries and Idempotency-Key
Send an Idempotency-Key header with any request to /v1/ingest/events, /v1/ingest/billing-events or /v1/ingest/leads — any string, as long as it is unique per logical send. Mint a fresh random UUID for every new send (every new batch, every new lead) and reuse that SAME UUID only when retrying THAT send — never derive the key from the request's content. A content hash sounds tidy but breaks the one case the key exists for: a batch that comes back rejected > 0, gets its bad rows fixed, and gets resent needs a NEW key (see the warning under "Partially accepted batches" above) — a hash of "the batch" has no way to know the batch changed if you keep re-deriving it the same way. Do not fold the submission time into the key either: a double click, or a retry loop that stamps its own timestamp, would produce a different key on every attempt and the protection is gone.
Never put an email, a name, or any other personal data in the key itself. Unlike the fields in your payload, a caller-chosen key has no masking on our side — it can appear in our support diagnostics if you ever open a ticket about a specific request. A random UUID sidesteps the question entirely.
LealUp remembers the key for 24 hours and, on a repeat, replays the EXACT SAME response it gave the first time — same status code, same body — instead of doing the work again:
- Same key, same body → the original response, replayed. The response carries the
Idempotent-Replay: trueheader, so you can tell a replay from a fresh run; nothing new is written. On/leadsthat means the samecustomer_id/contact_id/opportunity_idcome back, not a second account. - Same key, a previous request with it is still running →
409. Two overlapping requests with the same key (a naive double-submit, not a deliberate retry) get this instead of both running at once. Wait about a second and send the exact same request again — theRetry-Afterheader tells you how long — and by then the first one will have finished, giving you its response replayed. If our own process gets interrupted mid-request (a deploy, a crash) the key can stay blocked for up to a minute before it frees up on its own — keep retrying with backoff rather than giving up. - Same key, different body →
422. Reusing a key for what is not actually a retry of the same request is a caller bug, not a duplicate — replaying the wrong response would be a silent data-integrity failure, so this is rejected instead. The comparison is over the raw, byte-identical request body, not its meaning — an SDK that re-serializes JSON on a retry (reordering keys, for instance) can trip this even though "the request" looks the same to you; resend the exact bytes. - On a timeout or a 5xx, retry with the SAME key — that is exactly what we ask you to do.
The guard above is best-effort. On the rare occasion our cache is unavailable, a retry is processed as a brand-new request instead of being recognized as a repeat — you simply will not see Idempotent-Replay: true, and the request runs like normal. For billing events specifically, that is worth planning for: keep your own record of what you already sent, the same as you would if you were not sending a key at all, since this is the one case where a duplicate is a real cost rather than a re-rendered screen. (/v1/ingest/leads is the exception — see "Retries past 24 hours" right below: a lead retry can still be recognized, and a mismatched one still rejected, well past a cache outage.)
Retries past 24 hours (/v1/ingest/leads only)
For lead submissions specifically, a second, longer-lived record survives for 90 days — much longer than the 24-hour cache above. If the cache no longer remembers your key (the 24 hours passed, or it was briefly unavailable) but the ORIGINAL request with that same key already fully landed, you still get back the original 201 — same customer_id/contact_id/opportunity_id — this time flagged with replayed_from_ledger in warnings instead of the Idempotent-Replay: true header. Check for either signal if you want to reliably detect a replay beyond the first day. The same-body/different-body rule above still holds too: a mismatched retry still gets a 422, even past 24 hours or during a cache outage — it is rejected, never silently applied to the wrong lead. /v1/ingest/events and /v1/ingest/billing-events do not have this second record; for those two, once the cache no longer remembers a key, a retry genuinely runs as a brand-new request.
Without Idempotency-Key there is no duplicate protection: two sends of the same request — a user's double click on a form, a retry loop that mints a fresh key every time — are each processed in full, and on /leads that means two accounts.
Limits
| Limit | Value |
|---|---|
| Requests per minute (events + billing) | 300 per organization |
| Lead requests per minute | 60 per organization |
| Concurrent requests (events + billing) | 8 at a time, per organization |
| Concurrent lead requests | 8 at a time, per organization |
| Usage events per batch | 100 |
| Billing events per batch | 50 |
| Request body size | 1 MiB |
The 300 per minute limit is shared across the events and billing endpoints and counted per organization, not per key and not per IP. POST /v1/ingest/leads has its own 60-per-minute allowance — it does not compete for quota with events or billing. Both are defaults: if your volume justifies it, either can be raised for your organization.
Two independent ceilings apply, not one. The per-minute table above bounds volume; a separate ceiling bounds concurrency — how many requests of yours we are serving at the exact same instant. Even well under your per-minute budget, sending more than 8 requests to the events/billing endpoints (or 8 to leads) at once gets the newest ones a 429 with Retry-After: 1 — a one-second hint, not a minute, since a burst clearing a moment later is expected to succeed. If your integration fires requests in parallel, cap that parallelism client-side rather than relying on us to queue it for you.
Rate-limit headers
A successful response, and a 429 from the per-minute limit, both carry six headers describing that volume window:
| Header | Meaning |
|---|---|
RateLimit-Limit / X-RateLimit-Limit | Requests allowed per minute for this endpoint's tier |
RateLimit-Remaining / X-RateLimit-Remaining | Requests left in the current one-minute window |
RateLimit-Reset / X-RateLimit-Reset | Seconds until the window resets |
The RateLimit-* names are the IETF draft spelling; X-RateLimit-* is the older, widely-supported one — both carry identical values, so use whichever your HTTP client already knows. Read RateLimit-Remaining to pace yourself proactively instead of waiting to be rejected: that is the whole point of shipping these headers instead of just a 429.
These six headers are specifically about the per-minute volume budget, so they are absent whenever that budget was never consulted: a 429 from the concurrency ceiling above (it denies before touching the per-minute window, so it has nothing fresh to report — only Retry-After: 1), and any response where the limiter itself could not run (no organization context, or our cache briefly unreachable — LealUp fails open rather than blocking your traffic on our own infrastructure).
Request body size
JSON requests to these endpoints are capped at 1 MiB — far above real traffic (a 100-event batch is roughly 50 KB) and there only to reject a pathological payload before it is read. An oversized body gets a 413 before LealUp does any work on it.
For high volume, batch: 100 events in one request cost the same as 1.
Errors
Every error response is application/problem+json, following RFC 7807. The shape is always the same — type, title, status, an optional detail, the trace_id and the instance (the URL you called) — so one parser covers every status code. Some errors add extension members on top: errors on a 422, retry_after on a 429.
Every response, successful or not, also carries the X-Trace-Id header, the same value as trace_id in the body. Keep it: it is the first thing support will ask for.
401, something wrong with the key
{
"type": "https://lealup.com/errors/401",
"title": "Unauthorized",
"status": 401,
"detail": "Missing X-API-Key header",
"trace_id": "01a081cb9c0afe8feb35cfa5cc830bc8",
"instance": "https://api.lealup.com/v1/ingest/events"
}You get this when the X-API-Key header is missing or the key is invalid (detail becomes Invalid API key). The most common cause is regenerating the key and forgetting to update the environment variable.
422, the body fails validation
{
"type": "https://lealup.com/errors/validation",
"title": "Validation error",
"status": 422,
"detail": "events: List should have at most 100 items after validation, not 101",
"trace_id": "01a081cbaaeede9ff4e3549c2671d656",
"instance": "https://api.lealup.com/v1/ingest/events",
"errors": [{ "field": "events", "code": "too_long" }]
}Read errors, never detail. errors is the contract: one entry per problem, with a field and a code you can branch on and turn into your own copy, in your own language. detail is prose for whoever reads a log or opens a ticket — it is English, it can quote a third-party validator word for word, and its wording is not guaranteed between releases.
fieldis the path to the offending field, with the request part stripped:events.0.event_typeis theevent_typeof the first event in the batch. A rule about the whole payload — including a body that is not valid JSON — comes back as__root__.codeis the Pydantic error type, a stable key:missing,too_long,too_short,string_too_short,value_error,json_invalid, among others.
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: the batch still returns 202 and names the event in that response's own errors list.
409, another request with this key is still running
{
"type": "https://lealup.com/errors/idempotency-key-in-flight",
"title": "Duplicate request",
"status": 409,
"detail": "A request with this Idempotency-Key is already being processed. Retry after a second.",
"trace_id": "01a081cb8f6fe69f4f83521b68c997ff",
"instance": "https://api.lealup.com/v1/ingest/leads"
}On any of the three ingest endpoints, when two requests carrying the SAME Idempotency-Key overlap — typically a naive double-submit, not a deliberate retry. You should only ever see this while the first request is still in flight. Wait about a second (the Retry-After header says exactly how long) and send the exact same request again with the same key: by then the first one will have finished, and you get its response replayed — with the Idempotent-Replay: true header — not a second 409. If you get this right after the first request came back as a 5xx, something is off: by design, a failure on LealUp's side releases the key so the retry is actually processed (see "Retries" above).
422, the Idempotency-Key was reused with a different body
{
"type": "https://lealup.com/errors/idempotency-key-reused",
"title": "Idempotency-Key reused with a different body",
"status": 422,
"detail": "This Idempotency-Key was already used for a request with a different body. Use a new key for a different request.",
"trace_id": "01a081cb8f6fe69f4f83521b68c997ff",
"instance": "https://api.lealup.com/v1/ingest/leads"
}You get this when you send the same Idempotency-Key on a request whose body does not match the one that key was first used for. This is NOT the validation 422 above — it carries no errors array — it means the key is being reused for what is not actually a retry of the same request. Mint a new key for a genuinely different request; never reuse one across two different batches or leads.
429, you went over the limit
{
"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.",
"trace_id": "01a081ce4a405c7b01b70a30e8076405",
"instance": "https://api.lealup.com/v1/ingest/events",
"retry_after": 37
}retry_after is a top-level member of the problem object — the seconds left, as a number. The response also carries the Retry-After header with that same value; honour it instead of retrying immediately.
500, something broke on our side
{
"type": "https://lealup.com/errors/500",
"title": "Internal Server Error",
"status": 500,
"detail": "Failed to ingest events",
"trace_id": "01a081cbd3e14f8a97c2b0d1e6a45f77",
"instance": "https://api.lealup.com/v1/ingest/events"
}detail names the endpoint that failed: Failed to ingest billing events on /v1/ingest/billing-events, and a generic An unexpected error occurred on /v1/ingest/leads, where the route does not catch the error itself. Key on status and type — never on that prose. That generic response may also arrive without the X-Trace-Id header and without the trace_id member; when it does, note the exact time of the request and the endpoint you called, which identify it just as well.
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 — or with that time and endpoint when it is missing.
503, the query took too long
{
"type": "https://lealup.com/errors/query-timeout",
"title": "Query Timeout",
"status": 503,
"detail": "The database took too long to answer this request. Try again shortly.",
"trace_id": "01a081cf12d3e4f5a67b8c90d1e2f3a4",
"instance": "https://api.lealup.com/v1/ingest/events"
}Every request is bounded by a 5-second query timeout on our side. This is rare — it means the database took unusually long on this particular request — and it is retry-safe: nothing was written when it happens. Retry with backoff, the same as a 429 or a 500.
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.
- A retry without
Idempotency-Keyresends. If a request dies on timeout and you cannot tell whether it landed, a retry with no key may record the events twice. Send anIdempotency-Keyon every batch (see "Retries" above) and a retry replays instead — for adoption metrics an occasional double-send is usually tolerable, but the key is the fix, not a workaround. - 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
rejectedand log theerrors. 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 — with a NEW Idempotency-Key if you sent one the first time (see the warning under "Partially accepted batches"): the old key still points at the response that had the rejection in it.
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.