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.

Software architecture and API design diagram
Photo by Compagnons on Unsplash

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:

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

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


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.