Designing a fiscal API: five principles we built Kreyo on
Fiscal APIs operate at a higher stakes level than most B2B products: mistakes don't just produce bugs, they produce legal and financial consequences for your customers. Here are five design principles that shaped how Kreyo is built, not as a marketing exercise, but as actual constraints we applied from day one.
Principle 1: Idempotency by default for anything that touches a government system
Any endpoint that produces side effects outside your system — submitting a VeriFactu record to AEAT, delivering a FacturaE to FACe, generating a PDF that gets emailed — must be idempotent. The caller must be able to safely retry on timeout without worrying about duplicates or double-registrations.
The reason is simple: network reliability is not guaranteed, and government APIs (AEAT specifically) can process a request and return a timeout before sending the acknowledgement. A caller who retries after a timeout needs a guarantee that the second call either succeeds cleanly or tells them the first call already worked.
In practice, idempotency in a fiscal context means:
- Every submission endpoint accepts an optional
idempotency_keyin the request header. Same key = same result, no re-processing. - For VeriFactu, AEAT's own error code
1105(registro ya existente) is treated as success; it confirms the original submission was accepted. - Chain state is only advanced after the submission is confirmed accepted. A pending submission doesn't advance the chain.
# Idempotent VeriFactu submission
curl -X POST https://api.kreyo.io/verifactu/v1/register \
-H "Authorization: Bearer sk_live_..." \
-H "Idempotency-Key: invoice-2026-00042-v1" \
-H "Content-Type: application/json" \
-d '{ "invoice": {...} }'
# If retried with same key: returns cached response from first successful call
Principle 2: Errors must be legible, not opaque
The worst error an API can return is a generic 500 with no context. The second worst is a well-structured error body that doesn't tell you what to do about the problem. Both are common in fiscal APIs, and both waste the developer's time in exactly the moment when they're already stressed.
Kreyo's error envelope has four required fields:
request_id: a unique identifier for this specific request, present in every response including successes. This is the key you give to support, to your own logs, and to your own customers when something goes wrong.code: a machine-readable string insnake_case. Examples:nif_invalid,certificate_expired,aeat_chain_break. Stable across API versions so callers can match on it.message: a human-readable sentence that tells the developer what went wrong and (usually) what field or value caused it.hint: optional, but present for errors that have a known fix. Points to the relevant documentation or explains the remediation step.
{
"error": {
"request_id": "req_01JVMN8Q...",
"code": "nif_invalid",
"message": "The issuer NIF 'B1234567X' failed the CIF checksum validation.",
"hint": "Verify the last character of the NIF. For CIFs starting with B, the control character can be a digit or a letter from JABCDEFGHI."
}
}
The request_id is in every response because it's needed for observability — covered in
Principle 5. The hint is the field that distinguishes an API built by people who have
debugged their own API from one that hasn't.
Principle 3: The client owns their sensitive data
Fiscal APIs handle three categories of sensitive data: certificates (private keys), personal identification numbers (NIFs of individuals), and invoice contents (which can be commercially sensitive).
The architecture decision that follows from this is: the API should be stateless with respect to sensitive data wherever possible. Certificates should be passed in the request and used within it — not stored. PII should not be logged. Invoice data should not be retained beyond what's necessary to process the request.
This is the component model described in the Colaborador Social vs Componente post: the client passes their certificate, the API uses it to sign and authenticate, and the certificate is not persisted. The API is a neutral processor, not a data holder.
The consequence for API design is that certificate handling must be designed for pass-through:
- Accept PFX/P12 format (the universal container for certificate + private key).
- Use the certificate within the request scope. Release it from memory after the response is sent.
- Never log the PFX contents, the password, or any derived key material.
- Document explicitly what data is retained (request metadata, response status) and what isn't (certificate, invoice line items).
Principle 4: Documentation versioned with the API
Docs that drift from the actual API behavior are worse than no docs; they build false confidence. A developer who reads that field X is optional, implements accordingly, and then gets a validation error in production has been actively harmed by the documentation.
The only reliable way to prevent docs drift is to generate docs from the API definition. In Kreyo's case, that means the OpenAPI spec is the source of truth. It's generated by the ASP.NET Core application at startup from controller attributes and XML doc comments. The documentation site reads the spec and renders it. If a field changes, the spec changes, and the docs change automatically.
Versioning is explicit: the API URL includes the version (/v1/), and a new major version
is only introduced when a breaking change is unavoidable. Minor additions — new optional fields, new
response properties — are backward-compatible and don't require a version bump.
// C# — OpenAPI description from controller
[HttpPost("v1/register")]
[ProducesResponseType<VeriFactuRegistrationResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<KreyoError>(StatusCodes.Status422UnprocessableEntity)]
/// <summary>Register an invoice with AEAT via VeriFactu.</summary>
/// <remarks>
/// Idempotent. Pass the same Idempotency-Key to safely retry on timeout.
/// The client certificate is used within this request and not stored.
/// </remarks>
public async Task<IActionResult> Register(
[FromBody] VeriFactuRegistrationRequest request,
[FromHeader(Name = "Idempotency-Key")] string? idempotencyKey)
{
// ...
}
Principle 5: Observability as a product feature, not an afterthought
When a VeriFactu submission fails in production at a customer's site, the developer debugging it has limited tools: they can't reproduce the issue locally (it involves the customer's certificate and AEAT's state), they can't ask the customer to send their certificate, and the AEAT web service's error messages aren't always specific enough to identify the root cause.
This means the API must make debugging possible from the outside. Three specific requirements:
Every request has a request_id that's a contract, not an implementation detail.
It appears in the response body, in the response headers (X-Request-Id), and in the logs.
A customer can give you a request_id and you can find the exact request within seconds.
Error codes are stable across versions. If you're monitoring for
aeat_chain_break in your alerting, a minor API update shouldn't break your alert. Error
codes are part of the API contract, not internal implementation detail.
The response always tells you what AEAT actually said. When AEAT returns an error, the Kreyo response includes both Kreyo's interpretation of the error and AEAT's raw code and description. You don't have to guess whether the error is on Kreyo's side or AEAT's side.
{
"error": {
"request_id": "req_01JVMN8Q...",
"code": "aeat_submission_rejected",
"message": "AEAT rejected the submission for record 2026-00042.",
"aeat": {
"code": "1108",
"description": "La huella del registro anterior no coincide con el valor registrado en la AEAT."
},
"hint": "The HuellaAnterior value in your EncadenandoRegistro does not match the hash stored by AEAT. This usually means a previous submission was retried and the chain state diverged. Query the last accepted hash for your NIF and resume from there."
}
}
The pattern that connects all five
These five principles share a common thread: they exist to reduce the cost of errors in a domain where errors have consequences. Idempotency prevents duplicate registrations. Readable errors reduce debugging time. Data ownership limits exposure. Versioned docs prevent misintegration. Observability makes the inevitable errors recoverable.
None of them are novel. All of them are regularly skipped in the name of shipping faster. In a fiscal context — where a missed VeriFactu registration or an incorrect NIF on a submitted invoice can trigger regulatory attention — the shortcuts have a higher cost than they appear to at the time.
Building these in from day one is cheaper than retrofitting them when the first production incident surfaces.