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 exploring

API 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 engineYes, the same oneYes
Is billedNoYes
Which readings it seesOnly those made with test keysOnly those made with live keys
Quota it consumeslimits.test_quota_per_monththe plan's limits.monthly_quota
Needs a subscription or creditNoYes

"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:

  1. Issue the new key and store it wherever you keep secrets.
  2. Deploy with the new one. Both keys work at the same time: nothing has to switch over at once.
  3. Check in the list of keys that the old one is no longer used. Every key carries a last_used_at that 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.
  4. Revoke it with DELETE /api/admin/api-keys/{id}. Revoking is idempotent and takes effect on the next request: from then on that key answers 401 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_key

If 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[]:

FieldWhat it is
waitSeconds (0-60) the response waits for the readings to finish. Default 0
metadataA JSON string with a flat object, up to 20 scalar fields. Returned as it is, and filters the list
originYour 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 Reading object that GET /api/v1/invoices/{id} returns. There is no separate format for the webhook.
  • Two events and no more: reading.completed and reading.failed.
  • Headers: X-Lince-Event, X-Lince-Delivery-Id, X-Lince-Signature and a fixed User-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 2xx within 10 seconds. The body of your response is ignored. Answer quickly and do the work afterwards.
  • A 3xx is a failed attempt: redirects are not followed. Moving the endpoint with a 301 does not work; you have to change the URL with PUT /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-Id values 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/invoices returns those readings anyway.
  • Deduplicate by X-Lince-Delivery-Id, which is the same across the five attempts. A delivery can be repeated because your 2xx arrived 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_reused with param: Idempotency-Key.
  • "The body" here means the SHA-256 of the files in order, the metadata and the origin. Changing only the origin already 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.codeWhat happened
unsupported_file_typeIt is not a PDF
file_too_largeIt weighs more than limits.max_file_mb
too_many_pagesIt has more pages than limits.max_pages
unreadable_documentThe 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"
  }
}
  • code is the stable part: the string to hook your switch on. The message is in English and is for machines and logs; its wording can change without notice.
  • param says which field is to blame in a 422 (files, files.2, wait, metadata, Idempotency-Key…), and is null when it does not apply.
  • docs_url points 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_id is the req_… 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 defaultProfile marketer
rate_per_minute30120
max_files_per_request520
max_file_mb810
max_pages1010
max_concurrent_readings520
test_quota_per_month50200
max_active_api_keys1020

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

CodeHTTPRetry?
rate_limit_exceeded429Yes. Honour Retry-After; the response also carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset
concurrency_exceeded429Yes, 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_exceeded429No, not within the month. The contracted readings are used up: retrying does not bring them back
daily_limit_exceeded429No, not today. It resets at midnight Europe/Madrid
insufficient_credit402Never. Credit is needed; it only affects live
payment_required402Never. The subscription is not active or the trial has ended. test keys keep working
account_suspended403Never
validation_failed422Never. Fix the request; param says where
idempotency_key_reused422Never with that body
payload_too_large413Never. Split the batch
invalid_api_key401Never
internal_error500Yes, 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_address is what the engine read off the page, untouched. If the invoice prints a typo, the typo is here.
  • address is the address as normalised by the service, with its origin (source) and its geo block 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:

ElectricityGas
rate2.0TD, 3.0TD or 6.1TDRL.1 to RL.5
consumptionPer period: p1…p6A single total, in kWh
Fixed termpower_prices (contracted power per period) and costs.powerfix_term_prices (price per day and days billed) and costs.fix_term
costs.tax_energyElectricity excise taxHydrocarbons 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:

  1. The subscription is active. A live key without an active subscription, or with the trial over, answers 402 payment_required on every call. The test one would keep working, so this is not found out by testing in test.
  2. There is credit. Every live reading deducts from it, and admission checks the credit against the N readings of the request: if it does not reach, the POST answers 402 insufficient_credit and creates none.
  3. The live limits are different. test_quota_per_month has nothing to do with the plan's monthly_quota. Look at GET /api/v1/account again with the new key.
  4. Test readings do not cross over. Do not expect to see in live anything you tried in test: they are two separate worlds, and the id of a test reading answers 404 to a live key.
  5. The webhook belongs to the account, not to the mode. As you will know if you have tried it: test readings trigger deliveries too. If the URL points at your testing environment, change it first, or the production deliveries will go there.
  6. You are storing the request_id of every call in your log.
  7. 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.