Every truck that moves goods for hire inside Spain has to carry a control document, the DeCA (documento de control administrativo). It has existed since 2013 (Orden FOM/2861/2012): who hired the transport, who actually drives it, origin, destination, goods and weight. For years it was a sheet of paper in the cab.
Since 5 October 2026 it has to be digital. Law 9/2025 made it mandatory, and a resolution of 5 June 2026 says what "digital" means: a native PDF (not a scan, 5 MB at most), with a QR code inside it that points to that document's own HTTPS URL. Opening the URL has to download the PDF directly, with no login, no password and no "download" button, at least until the transport is over. The driver carries a copy with the QR, on the phone or printed. If you want the full list, we wrote it up in what a digital DeCA has to look like.
We build DecaFirma, an app that issues the DeCA, gets the delivery note signed on the customer's phone and handles delivery routes. Most transport companies issue from the web or the Android app. The ones with an ERP don't want to type the same shipment twice, and that's what the API, the webhooks and now an n8n node are for. This post is about the decisions that law forced on the API, and about publishing the node.
A correction is a new version, not a new document
Trucks get loaded, then the plate changes, or the weight, or the consignee. The resolution allows exactly two ways to change an issued DeCA: edit the same PDF (add the new data and the reason, keep the old data marked as no longer valid) or issue a brand new PDF with its own URL and QR. Section seven is blunt about it: those are the only valid ways.
The second option sounds simpler and is worse in practice. The driver already has the first QR on their phone, and an inspector scanning it would get the old data. So in our API an update never creates a new document:
PATCH /api/v1/shipments/:id
It produces a new version of the same document, at the same URL, so the QR the driver is already carrying keeps working and always serves the latest PDF. The old version is kept. That's how we read how to modify a DeCA. Changing the vehicle has its own shortcut, PATCH /shipments/:id/vehicle, so an ERP doesn't have to resend the whole shipment for a new plate.
Retries must not issue a second legal document
ERPs retry. Webhook senders retry. n8n retries if you tell it to. With most APIs a duplicate is an annoyance. Here a duplicate is a second legal document for the same transport.
So creation is idempotent by reference. If you create a shipment (or a route) with a reference that already exists in the company, you get the existing one back with duplicate: true and nothing new is issued. Put the order number in reference and you can retry as much as you like.
Save first, issue second
The data comes from someone else's system, and it is not always right. The classic one is a tax ID that doesn't validate. Our first instinct was to reject the request. But then the shipment is lost, and the person fixing it has to go back to the ERP and send it again.
Now POST /shipments always saves the shipment, and then tries to issue the DeCA, the delivery note or both. If issuing fails, the response carries the error code, a details.reason and the id of the saved shipment. You fix the field with PATCH and call POST /shipments/:id/issue. In the n8n node this shows up as a failed item with all three pieces of information, and with Continue On Fail you can route it to whoever fixes the data.
Error codes are stable strings (validation_error, unprocessable, rate_limited, addon_required…), so workflows can branch on them instead of parsing messages.
Webhooks: one URL per company, signed
Events are what make the integration worth it: document.issued, document.updated, document.voided, delivery_note.signed, cmr.signed, attachment.received (when the driver uploads a paper for the shipment).
Every delivery carries X-DecaFirma-Signature: sha256=<hex>, an HMAC-SHA256 of the raw body with the webhook secret. The trigger node checks it like this:
import { createHmac, timingSafeEqual } from 'crypto';
export function verifyDecaFirmaSignature(secret: string, rawBody: string | Buffer, header?: string) {
if (!header?.trim()) return { valid: false, reason: 'missing' };
const match = /^sha256=([0-9a-f]{64})$/i.exec(header.trim());
if (!match) return { valid: false, reason: 'malformed' };
const expected = createHmac('sha256', secret).update(rawBody).digest();
const given = Buffer.from(match[1], 'hex');
return given.length === expected.length && timingSafeEqual(given, expected)
? { valid: true }
: { valid: false, reason: 'mismatch' };
}
Two details matter. You sign and verify the raw bytes, not the parsed JSON, because re-serialising can change the order of keys or the whitespace. And the length check comes before timingSafeEqual, which throws on buffers of different length.
The design choice with the most consequences is that a company has one webhook URL. It keeps delivery simple and the history readable, but it means the n8n trigger and our Odoo module compete for it. When you activate a workflow, the trigger registers its URL with POST /api/v1/webhook. If the company already sends webhooks somewhere else, the trigger refuses to take the URL over unless you turn on Replace Existing Webhook. When you deactivate it, it only deletes the webhook if the URL is still its own. Silently stealing the Odoo connection because someone tested a workflow would be the worst possible failure.
Failed deliveries are not retried, so the node also has Document → Get Many with since. A scheduled workflow that stores the last changedAt and asks for everything after it catches whatever a webhook missed.
Publishing the node
The node itself has no runtime dependencies: the HTTP calls go through n8n's own request helper, and the signature check uses crypto. Most of the work was in publishing it the way n8n wants for verified community nodes.
Provenance is required. n8n's checker (npx @n8n/scan-community-package <package>) checks provenance, fetches the source from the commit the package says it was built from, and analyses it. A package published from a laptop has no provenance, so we publish from GitHub Actions with npm publish --provenance on a version tag.
lts/* will bite you. Our first workflow used node-version: 'lts/*'. On the day we published, that resolved to Node 26, and isolated-vm, a native module that n8n-workflow (a dev dependency of every node) pulls in through its expression runtime, failed to build. Running the scanner locally on Node 21 fails the same way. Pinning node-version: '22' fixed both. In a release workflow, "whatever is newest today" is not a version.
Trusted publishing comes after the first publish. npm only lets you set up a Trusted Publisher (OIDC from GitHub, no token) on a package that already exists. So the first version goes out with a short-lived token stored as a repository secret. After that you switch to OIDC and delete the token.
Try it
In n8n: Settings → Community Nodes → Install and enter @francodesystems-npm/n8n-nodes-decafirma. The credential is an API key that starts with dfk_; the credential test calls GET /api/v1/series, which changes nothing. The full API reference is on the developers page, and the node is MIT on GitHub.
A typical flow is three nodes. A trigger from your shop or ERP, then Shipment → Create with issue: both and the order number as reference, then send the driver the public URL and the customer the signing link. Going the other way, the DecaFirma Trigger on delivery_note.signed plus Document → Download PDF puts the signed delivery note back in your ERP.
If you integrate other countries' transport documents (eCMR, e-FTI), I'd like to hear how you handle corrections. The "same URL, new version" rule is the part I'd most like to compare.
Top comments (0)