Integration guide
From zero to a completed reading, and from there to production. What this page adds is the order
and the decisions: what comes before what, and which way to go at each fork. The detail of every
endpoint —parameters, schemas and field-by-field examples— is in the API reference, which is
generated from the contract; the raw document is /openapi.yaml. What each console
screen does is in the console manual.
The examples are curl and can be pasted as they are. First, two variables:
export LINCE_HOST="https://the-service-host" # the one we gave you
export LINCE_KEY="sk_test_your_test_key" # a test key: never a live one for exploringAPI keys and the two modes
An API key is a string with a mode prefix: sk_live_… or sk_test_…. The prefix is followed by 43
random characters. The service does not store the secret: only its SHA-256 and the first twelve
characters, which are what you later see in the list of keys.
The two modes share the code and nothing else:
sk_test_ | sk_live_ | |
|---|---|---|
| Runs the real engine | Yes, the same one | Yes |
| Is billed | No | Yes |
| Which readings it sees | Only those made with test keys | Only those made with live keys |
| Quota it consumes | limits.test_quota_per_month | the plan's limits.monthly_quota |
| Needs a subscription or credit | No | Yes |
"It only sees its own" is literal: asking a test key for a reading made in live answers
404 resource_not_found, exactly as if it belonged to another account.
There is no key that sees both.
Where they are created. In the console, on the API keys screen, which calls
POST /api/admin/api-keys with a name and a mode. The secret field of that response is the only
time the secret exists outside your code: if it is lost, it cannot be recovered; you issue another.
A live key can be created without an active subscription, but it will not accept readings until
there is one.
What each key can do. A key carries one or more scopes: read (the GET calls: readings, usage
and account), write (send invoices and give feedback) and delete (delete readings). When you create
it in the console all three come ticked; untick what that integration does not need. A call that needs
a scope the key does not have answers 403 insufficient_scope and does
nothing. Scopes are not changed afterwards: issue another key and revoke the old one.
The number of active keys per account is capped (limits.max_active_api_keys); above it,
creation answers 422 api_key_limit_reached. Revoked keys do not
count.
How to rotate without an outage. In this order, and never the other way round:
- Issue the new key and store it wherever you keep secrets.
- Deploy with the new one. Both keys work at the same time: nothing has to switch over at once.
- Check in the list of keys that the old one is no longer used. Every key carries a
last_used_atthat is refreshed at most once a minute: it is a hint, not an audit, but it is enough to see whether something you thought was switched off is still using it. - Revoke it with
DELETE /api/admin/api-keys/{id}. Revoking is idempotent and takes effect on the next request: from then on that key answers401 invalid_api_key.
Authentication
The key goes in one of these two headers:
Authorization: Bearer sk_test_your_test_key
X-Api-Key: sk_test_your_test_keyIf both arrive, X-Api-Key takes precedence. There is a reason: where an IAP takes over
Authorization with its own token, it is the only way for your key to arrive intact.
No key, an unknown one or a revoked one: 401 invalid_api_key. With the
account suspended: 403 account_suspended — what has already been read
can still be fetched; what is refused is new readings.
The whole API sits under /api: integrations call /api/v1/… (with an API key) and account
administration calls /api/admin/… (as a person, with an Identity Platform ID token, not with a
key). The paths without that prefix —/v1/… and /admin/…— still answer, but they are a transitional
alias that exists so as not to break whoever already called them: do not use them in a new
integration.
The first call, which uses no reading and confirms the key is valid:
curl -sS "$LINCE_HOST/api/v1/account" \
-H "Authorization: Bearer $LINCE_KEY" \
-H "Accept: application/json"Inside it, under limits, are all your account's limits, already resolved. Come back to this call
whenever something answers 429: the numbers on this page are the profile's, and your account's are
the ones the endpoint returns.
The life of a reading, end to end
A reading goes like this: you upload a file, the service queues it, a worker reads it and the reading
ends in completed or in failed. The four statuses are queued, processing, completed and
failed; the last two are final.
1. Upload
POST /api/v1/invoices with multipart/form-data. PDF only. One file per reading, one to N files
per request, and the response is always a list, also with a single file:
curl -sS -X POST "$LINCE_HOST/api/v1/invoices" \
-H "Authorization: Bearer $LINCE_KEY" \
-H "Accept: application/json" \
-F "files[]=@invoice-march.pdf" \
-F "wait=30" \
-F 'metadata={"batch":"batch-3"}'Body fields, besides files[]:
| Field | What it is |
|---|---|
wait | Seconds (0-60) the response waits for the readings to finish. Default 0 |
metadata | A JSON string with a flat object, up to 20 scalar fields. Returned as it is, and filters the list |
origin | Your own label for where the request comes from (^[a-z0-9][a-z0-9._-]*$, up to 64). Returned in origin, and filters the list |
metadata is a string because multipart does not carry objects: if you send something that is not
a JSON object, the whole request answers 422 validation_failed with
param: metadata.
What we do with the file. It is read and the reading is returned to you. It does not enter the set we tune the engine with, which is our own material, gathered separately, and is not fed from the service's traffic. The separation is upheld by a test that fails if anyone connects the two; the detail is in the manual.
How long it is kept. The PDF and the reading stay: today there is no automatic deletion and no expiry date, and they are kept in the EU, encrypted at rest. That is today's policy, not a perpetual promise. If you need us to delete something, it is done by hand and it is not immediate — the detail is in the manual.
2. Wait
Response with wait=30, when the reading finished within those seconds: 200 (trimmed; the full
object, field by field, is in the reference).
{
"object": "list",
"data": [
{
"id": "rd_01a073bc-ee8e-70a3-ad0d-be4ebf7dd7fd",
"object": "reading",
"status": "completed",
"mode": "test",
"api_key": { "id": "key_01a073bc-ee8e-70a3-ad0d-be4ebf7dd7fd", "name": "production", "prefix": "sk_test_EJEM" },
"created_at": "2026-09-05T22:42:36Z",
"completed_at": "2026-09-05T22:42:36Z",
"duration_ms": 1,
"file": {
"name": "invoice-march.pdf",
"bytes": 218,
"pages": 1,
"sha256": "c012d7f843bf9bfbe128cb3862eaf8ca85bf2b3f5634c1c347aab42646a83e2c",
"stored": true
},
"engine": { "version": "3.0", "model": "claude-sonnet-5" },
"review": { "needed": false, "mode": "flag", "threshold": 0.85, "confidence": 1, "resolution": null },
"invoice": {
"type": "electricity",
"bills": [
{
"id": "inv_01a073bc-ee9d-7355-864b-62037038536c",
"cups": "ES0031300666852008VE",
"rate": "2.0TD",
"initial_date": "2026-07-01",
"final_date": "2026-07-31",
"days_billed": 31,
"total_cost": 58.43,
"consumption": { "p1": 37, "p2": 41, "p3": 122, "p4": 0, "p5": 0, "p6": 0 }
}
]
},
"billing": { "price_eur": 0 },
"metadata": { "batch": "batch-3" },
"origin": null,
"error": null
}
]
}The example is an electricity invoice. How rate, consumption and the prices change on a gas or a
dual one, further down.
And if time runs out with a reading still in progress, 202 with the same body and the reading in whatever status it is in:
{
"object": "list",
"data": [
{
"id": "rd_01a073bc-ee8e-70a3-ad0d-be4ebf7dd7fd",
"object": "reading",
"status": "queued",
"mode": "test",
"completed_at": null,
"duration_ms": null,
"engine": null,
"review": null,
"invoice": null,
"billing": { "price_eur": 0 },
"error": null
}
]
}That is the whole rule for the status code: 200 if all of them finished, 202 if any is still in progress. It is never an error, and a 202 does not mean anything went wrong either.
3. Fetch
Once the reading has finished, GET /api/v1/invoices/{id} returns the same Reading object, and
GET /api/v1/invoices returns the account's list in the key's mode, with filters (status,
created_after, created_before, metadata[field]=value, origin, review_needed) and cursor
pagination.
curl -sS "$LINCE_HOST/api/v1/invoices/rd_01a073bc-ee8e-70a3-ad0d-be4ebf7dd7fd" \
-H "Authorization: Bearer $LINCE_KEY" \
-H "Accept: application/json"4. Delete
If you promised your users their invoice disappears after a while, DELETE /api/v1/invoices/{id}
erases the reading for good: the bills with the holder's data, the feedback, the webhook deliveries and
the PDF. It answers 204 and cannot be undone. What was billed stays: the balance and the daily usage
do not change.
A reading that is still being read, or whose cost has not been settled yet (a few minutes after it
closes), answers 422 reading_not_settled and nothing is deleted. One that does not exist or was
already deleted answers 404: if you delete on a schedule, count a 404 as done.
It needs a key with the delete scope. Every delete is recorded — which key, which reading, when and
with what result, nothing of the holder — and shows on the API keys screen of the console.
curl -sS -X DELETE "$LINCE_HOST/api/v1/invoices/rd_01a073bc-ee8e-70a3-ad0d-be4ebf7dd7fd" \
-H "Authorization: Bearer $LINCE_KEY"Three ways to get the result
There are three, and the choice depends on the volume and on who is waiting.
wait on the upload itself (0-60 s). The response stays open until every reading of that request
has finished or the time runs out. It is a single call and there is nothing to store. Use it when
a person is waiting for the result and there are few files. Bear in mind that it keeps the
connection open —yours and ours— the whole time: wait=60 on a large batch is a worker busy for a
minute on each side.
Polling the detail. Store the id the 202 gave you and call GET /api/v1/invoices/{id} until
status is completed or failed. Note that this endpoint answers immediately with what there is;
it does not hold the connection open; the only thing that waits is the upload's wait. It is
useful to resume a job that was cut short, or to watch a handful of ids. What bounds the pace is your
limit of requests per minute (limits.rate_per_minute), so space the calls out and never poll
without a pause between them.
Webhook. The service sends you a POST as soon as the reading finishes. It is the recommended
one for volume: no open connections and no polling. The URL and its secret are set with
PUT /api/admin/webhook, and every PUT with a URL generates a new secret (whsec_…) that is shown
only once.
The essentials, with the full instructions —signature verification in PHP and in Node included— in the reference:
- The payload is the same
Readingobject thatGET /api/v1/invoices/{id}returns. There is no separate format for the webhook. - Two events and no more:
reading.completedandreading.failed. - Headers:
X-Lince-Event,X-Lince-Delivery-Id,X-Lince-Signatureand a fixedUser-Agent,Lince-Webhooks/1.0. - The signature is
t=<unix>,v1=<HMAC-SHA256>over<t>.<raw body>with the account's secret. Sign the bytes exactly as they arrived: deserialising and serialising again changes them and the signature no longer matches. - Delivered = a
2xxwithin 10 seconds. The body of your response is ignored. Answer quickly and do the work afterwards. - A
3xxis a failed attempt: redirects are not followed. Moving the endpoint with a301does not work; you have to change the URL withPUT /api/admin/webhook. - Five attempts, four waits: 1 min, 5 min, 30 min and 2 h. After the fifth the delivery is marked as failed and abandoned —a little over two and a half hours after the first—.
- If your receiver was down for longer, we send them again. Write to us with the
X-Lince-Delivery-Idvalues you are missing, or with the time window in which it was down, and we send them again: with the same id, so your deduplication still holds, signed with your current secret and to the URL you have configured at that moment. There is no endpoint to request it yourself, and there is no need to wait:GET /api/v1/invoicesreturns those readings anyway. - Deduplicate by
X-Lince-Delivery-Id, which is the same across the five attempts. A delivery can be repeated because your2xxarrived late. - One delivery per finished reading is sent, and nothing more: whatever changes afterwards does not emit a second event.
The sensible thing is to combine them: the webhook for the normal case and a periodic sweep with
GET /api/v1/invoices?status=… as a safety net, which is also what covers you, without waiting for
anyone, when a delivery uses up its five attempts.
Idempotency
Send an Idempotency-Key header on every POST /api/v1/invoices:
curl -sS -X POST "$LINCE_HOST/api/v1/invoices" \
-H "Authorization: Bearer $LINCE_KEY" \
-H "Accept: application/json" \
-H "Idempotency-Key: batch-3-attempt-1" \
-F "files[]=@invoice-march.pdf" \
-F "wait=30"The rules:
- The scope is the API key, and the value is remembered for 24 hours. Two different keys can use the same string without colliding.
- Between 1 and 255 characters.
- Repeating it with the same body returns the same readings, without creating any new one and without charging again.
- Repeating it with a different body answers
422 idempotency_key_reusedwithparam: Idempotency-Key. - "The body" here means the SHA-256 of the files in order, the
metadataand theorigin. Changing only theoriginalready makes it a different body.
Why it matters. A network timeout while uploading a batch does not tell you whether the request arrived. Without the header you only have two bad options: not retrying and losing the batch, or retrying and creating —and paying for— the readings twice. With it, retrying is safe by definition, and the retry is recognised before it consumes concurrency, quota or credit. Use one value per logical attempt, not per HTTP request: if you retry the same batch, the value is the same.
A failed reading is not an HTTP error
This is the distinction a new integration gets wrong most often.
What goes wrong in the request —files is missing, wait is out of range, there is no credit—
returns the error object described below, and no reading is created. What goes wrong in a file
is not an HTTP error: the request went perfectly well and that particular reading is created
directly as failed, inside a 200, with its reason in error:
{
"id": "rd_01a073bc-ee8e-70a3-ad0d-be4ebf7dd7fd",
"status": "failed",
"file": { "name": "nope.txt", "pages": null, "stored": false },
"engine": null,
"review": null,
"invoice": null,
"billing": { "price_eur": 0 },
"error": {
"type": "processing",
"code": "unsupported_file_type",
"message": "Only PDF files are accepted"
}
}In a request with N files the list keeps the order of the request and freely mixes completed and
failed readings. Walk through data[] and look at status and error.code; the HTTP code on its
own does not tell you whether an invoice was read.
The four that are the file's fault, and none of them costs anything (billing.price_eur is 0 and
there is no charge):
error.code | What happened |
|---|---|
unsupported_file_type | It is not a PDF |
file_too_large | It weighs more than limits.max_file_mb |
too_many_pages | It has more pages than limits.max_pages |
unreadable_document | The PDF is damaged and does not open, or it opens and holds no readable invoice |
The first three —and the fourth, when the PDF cannot even be opened— are decided by a cheap check
before the engine is involved: that reading is already final when it is created, whether you set
wait or not. The unreadable_document that takes time is the other case, the document that opened
and had no invoice inside. There are also three codes that are ours, not yours:
model_unavailable,
engine_error and
never_started. Those do allow uploading the same file again.
The error format
One for the whole API. When the request does not go through, the body is always this:
{
"error": {
"type": "invalid_request",
"code": "validation_failed",
"message": "The files field is required.",
"param": "files",
"docs_url": "https://seleya.es/docs/errors#validation_failed",
"request_id": "req_01a073bc-eeaf-73b6-af43-1ae071158b3b"
}
}codeis the stable part: the string to hook yourswitchon. Themessageis in English and is for machines and logs; its wording can change without notice.paramsays which field is to blame in a 422 (files,files.2,wait,metadata,Idempotency-Key…), and isnullwhen it does not apply.docs_urlpoints at the specific entry of the error table, not at the page in general. The host is that of the service that answered you.request_idis thereq_…of that request.
X-Request-Id comes in EVERY response, successful or not, and in an error it is exactly the same
value as error.request_id. We always issue it: if you send the header yourself, it is ignored. And
everything carries it, including responses that never reach your route —a 404 for a route that does
not exist, or a 413 for an enormous body—.
What to send to support: the request_id. With it we find the request in the logs. Keep it in
your own log next to every call, including the successful ones: the day you need to reconstruct what
happened, it is the only piece of data that ties your trace to ours.
Quotas and limits
First things first: your account's limits are returned by GET /api/v1/account under limits. The
ones below are the values of each profile, which is where they come from unless the plan or the
account overrides them.
limits.* | Profile default | Profile marketer |
|---|---|---|
rate_per_minute | 30 | 120 |
max_files_per_request | 5 | 20 |
max_file_mb | 8 | 10 |
max_pages | 10 | 10 |
max_concurrent_readings | 5 | 20 |
test_quota_per_month | 50 | 200 |
max_active_api_keys | 10 | 20 |
The monthly quota of live keys (monthly_quota), the readings included and the prices come from
the plan. They are not repeated here on purpose, so that this page does not go stale the day they
change: the public plan catalogue is served by GET /console/config (public, no authentication) and
shown on the console's Billing screen, and what applies to your account is in
GET /api/v1/account.
Admission is all or nothing
Before creating any reading, the POST checks, in this order, concurrency → quota → daily cap →
credit, counting the N files of the request. The first protection that trips answers for the whole
request: either all N readings are admitted or none is. You will never find half a batch
created.
What to retry and what not
| Code | HTTP | Retry? |
|---|---|---|
rate_limit_exceeded | 429 | Yes. Honour Retry-After; the response also carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset |
concurrency_exceeded | 429 | Yes, with backoff. It is transient: wait for the readings you already have in progress to finish. Note that it carries no Retry-After: the backoff is up to you |
quota_exceeded | 429 | No, not within the month. The contracted readings are used up: retrying does not bring them back |
daily_limit_exceeded | 429 | No, not today. It resets at midnight Europe/Madrid |
insufficient_credit | 402 | Never. Credit is needed; it only affects live |
payment_required | 402 | Never. The subscription is not active or the trial has ended. test keys keep working |
account_suspended | 403 | Never |
validation_failed | 422 | Never. Fix the request; param says where |
idempotency_key_reused | 422 | Never with that body |
payload_too_large | 413 | Never. Split the batch |
invalid_api_key | 401 | Never |
internal_error | 500 | Yes, with backoff. It is ours. Keep the request_id |
The rule that sums up the table: the rate limit 429s (rate_limit_exceeded,
concurrency_exceeded) are retried with backoff; the budget 429s (quota_exceeded,
daily_limit_exceeded) and the 402s are not fixed by retrying, however fast or slow you do it. A loop
that treats the four 429s alike will spend the rest of the day retrying in vain.
Two notes on the limits that confuse people most. Going over max_files_per_request is not a
quota code, it is 422 validation_failed with param: files —a shape
error, not a budget one—, and max_concurrent_readings counts readings in progress, not
requests: twenty files in a single request take up twenty slots.
The address comes in two fields
Every bill carries the supply address in two fields that do not replace each other:
supply_addressis what the engine read off the page, untouched. If the invoice prints a typo, the typo is here.addressis the address as normalised by the service, with its origin (source) and itsgeoblock with coordinates and precision.
To match against your own data, supply_address. To draw a map or group by municipality, address.
The two rules to know before coding against them: address.geo is always an object —without
coordinates, its fields are null and fallback_reason explains why—, and a reading never fails
because it could not be geolocated.
The rest —which fields each one carries, what each source, each precision and each
fallback_reason means— is in the address and geolocation section of the reference, which is
where it is maintained.
Gas and dual invoices
The engine reads electricity, gas and dual invoices —one document that bills both
supplies—. invoice.type says which one it is, and invoice.bills carries one bill per
supply: one on an electricity or gas invoice, two on a dual one, each with its own invoice_type
and its own CUPS, because the gas supply point has one of its own. The life of the reading is the
same; what changes is which fields carry a value:
| Electricity | Gas | |
|---|---|---|
rate | 2.0TD, 3.0TD or 6.1TD | RL.1 to RL.5 |
consumption | Per period: p1…p6 | A single total, in kWh |
| Fixed term | power_prices (contracted power per period) and costs.power | fix_term_prices (price per day and days billed) and costs.fix_term |
costs.tax_energy | Electricity excise tax | Hydrocarbons tax |
The same reading as above had the PDF been a gas invoice, trimmed the same way:
{
"invoice": {
"type": "gas",
"bills": [
{
"id": "inv_01a073bc-f1a2-7c3e-9d4b-5e6f7a8b9c0d",
"invoice_type": "gas",
"cups": "ES0230000000000001SR",
"rate": "RL.2",
"initial_date": "2026-07-01",
"final_date": "2026-07-31",
"days_billed": 31,
"total_cost": 35.2,
"consumption": { "total": 512 },
"costs": { "energy": 18.4, "fix_term": 9.3, "tax_energy": 1, "tax_iva": 6.15, "service": 0.35, "power": 0 },
"fix_term_prices": [{ "price": 0.3, "unit": "day", "duration": 31, "discount": 0 }],
"power_prices": []
}
]
}
}On a dual invoice, type is "dual" and bills carries two objects like these, the electricity
one and the gas one. If you only care about one of the two supplies, filter on each bill's
invoice_type, not on invoice.type.
From test to live
If you have got this far with a test key, you already have the whole integration. Going to
production does not change a line of your code: it changes the key. Before you do, go over this:
- The subscription is active. A
livekey without an active subscription, or with the trial over, answers402 payment_requiredon every call. Thetestone would keep working, so this is not found out by testing in test. - There is credit. Every
livereading deducts from it, and admission checks the credit against the N readings of the request: if it does not reach, thePOSTanswers402 insufficient_creditand creates none. - The
livelimits are different.test_quota_per_monthhas nothing to do with the plan'smonthly_quota. Look atGET /api/v1/accountagain with the new key. - Test readings do not cross over. Do not expect to see in
liveanything you tried intest: they are two separate worlds, and the id of a test reading answers 404 to a live key. - The webhook belongs to the account, not to the mode. As you will know if you have tried it:
testreadings trigger deliveries too. If the URL points at your testing environment, change it first, or the production deliveries will go there. - You are storing the
request_idof every call in your log. - Your retries tell the rate limit 429s from the budget ones apart, and send
Idempotency-Key.
To watch usage, GET /api/v1/usage returns the account's daily usage in the key's mode —days
cut at midnight Europe/Madrid; by default, the current month—.
If something does not add up
Write to us with the request_id of the specific request. With that we usually have enough;
without it, almost never. The error's code and the approximate time (say which time zone) help,
but the request_id is what identifies your request among all of them.
This page was written and checked against the service's code. The limits, codes, times and field names that appear here come from that code, not from an earlier note: if any of them does not behave as this guide says, it is a bug on our side and we want to know.