Cairos
Invoicing
Invoicing softwareQuotesRecurring invoicesExpenses and suppliersReceipts and cash flow
Accounting and tax
AccountingAEAT tax formsRecord booksFixed assetsIGIC and the Canary Islands
Operations
Inventory and warehousesCRMTime trackingProjectsGrants and funding
Compliance
VeriFactuTicketBAIElectronic invoicingAll the regulationsSecurity and data
By type of business
Self-employedSmall businessesAccountants and tax advisersForeigners in SpainStartupsRetail and shops
By sector
Hospitality and restaurantsConstruction and renovationProfessional servicesE-commerceAll sectors
By legal structure
AssociationsFoundationsCooperativesSports clubsAll legal structures
Switching software
ComparisonsAn alternative to HoldedMigrating your data
Free tools
Invoice templateVAT calculatorIRPF calculatorAll the tools
Learn
GuidesGlossaryTax calendarBlog
Developers
API and documentationGet started in five minutesResource referenceWebhooks
Help
Help centreContact
Pricing
Start for free Log in
Integration

WooCommerce: from a paid order to an issued invoice

One hook, six calls and a header that prevents the duplicate. In PHP, which is where WooCommerce lives, and with the three Spanish decisions the shop does not come with out of the box.

The complete PHP codeNo dependenciesWithout invoicing twice

The API is up and running. This is what there is today and what there is not

It works. The routes in this documentation are deployed and responding at https://erp.cairos.es/api/v1/. The examples on these pages can be copied and run. The full specification is at openapi.json, which is what Make and n8n import.

You create the keys yourself, from Developers in your Cairos account. Start with a cai_test_ key: it works against your real data but does not record to VeriFactu and does not send emails, so you can build your integration without dirtying an invoice series.

And what there still is NOT, said plainly: no official connector. No Shopify app, no WooCommerce plugin, no published Make or n8n module. They can be built with the API and its specification — that is what the guides in this section are for — but building them is work, and that work is not done.

If you build something with this, write to us at hola@cairos.es. We are particularly interested in whatever you find missing from the contract: that is what decides which parts get extended first.

You no longer need to program this

Cairos connects to WooCommerce from inside the program: you switch on the online shops module, paste in the credentials your shop gives you and each order becomes an invoice with your series, your VAT and your numbering. There is no plugin to install in WordPress: the connection is made from Cairos with a WooCommerce REST API key, which is created in two screens.

This guide stays here for anyone who would rather build it their own way, who has a flow that does not fit, or who wants to know what the connector does inside before trusting it.

The journey

What the integration does, in three moves

The order matters: it is what stops a dropped connection ending in two invoices.

1

The order is paid

WooCommerce fires woocommerce_payment_complete. There, and not before, there is something to invoice.

2

It is checked and it is called

If the order already has an invoice saved, nothing happens. If not, the call is made with Idempotency-Key: wc-1042.

3

It is saved before going on

The invoice identifier is written on the order before issuing. That way a later failure does not erase the trail.

First things first: deciding when to invoice

It is the decision with the most consequences and the one almost nobody thinks about. WooCommerce moves an order through several states, and only one of them is the right one for issuing an invoice.

WooCommerce hookInvoice here?Why
woocommerce_new_orderNoThe order exists but may never be paid. You would be invoicing abandoned carts with the gateway left open.
woocommerce_payment_completeYesThe money has been collected. It is the natural moment: there is a chargeable event and there is a payment to record.
woocommerce_order_status_completedAlso validIf you sell physical goods and prefer to invoice on dispatch. Choose one of the two, not both.

If you hook both, an order will pass through both and you will call twice. With an Idempotency-Key derived from the order, nothing happens; without it, you have two invoices. That is the whole difference.

The NIF, which WooCommerce does not ask you for

A freshly installed WooCommerce shop asks for a name, an address and an email. It does not ask for the NIF, because the billing form is the international standard and knows nothing about Spain.

And without a NIF there is no full invoice: there is a simplified invoice, which has a cap on the amount and which your business customer cannot deduct. So before you write a line of integration you have to add that field to the checkout, with any of the Spanish invoicing add-ons out there, or by hand with WooCommerce's billing fields hook.

Then, in the code below, that field is what travels to the contact's nif. If it arrives empty, decide what you do: create the contact without a NIF and issue a simplified invoice, or stop and warn. What you must not do is invent a filler NIF, which is what ends up throwing the 347 out in February.

The hook, in full

This goes in a plugin of your own — a file in wp-content/plugins/ — not in the theme's functions.php: the day you change theme you do not want to lose your invoicing.

cairos-woocommerce.php
<?php
/**
 * Plugin Name: Cairos para WooCommerce
 */

define("CAIROS_BASE", "https://erp.cairos.es/api/v1");

function cairos_llamar($ruta, $cuerpo = null, $llave = null) {
    $cabeceras = [
        "Authorization" => "Bearer " . getenv("CAIROS_CLAVE"),
        "Content-Type"  => "application/json",
    ];
    if ($llave) {
        $cabeceras["Idempotency-Key"] = $llave;
    }

    $r = wp_remote_post(CAIROS_BASE . $ruta, [
        "headers" => $cabeceras,
        "body"    => $cuerpo ? wp_json_encode($cuerpo) : null,
        "timeout" => 20,
    ]);

    if (is_wp_error($r)) {
        throw new Exception($r->get_error_message());
    }

    $estado = wp_remote_retrieve_response_code($r);
    $datos  = json_decode(wp_remote_retrieve_body($r), true);

    if ($estado >= 400) {
        $codigo = $datos["error"]["codigo"] ?? "desconocido";
        throw new Exception("Cairos " . $estado . ": " . $codigo);
    }

    return $datos;
}

add_action("woocommerce_payment_complete", "cairos_facturar_pedido");

function cairos_facturar_pedido($id_pedido) {
    $pedido = wc_get_order($id_pedido);

    // 1 · Si ya lo facturamos, no hay nada que hacer. Esta línea
    //     vale más que todo el resto del fichero.
    if ($pedido->get_meta("_cairos_factura_id")) {
        return;
    }

    $llave = "wc-" . $id_pedido;

    try {
        // 2 · Contacto. El NIF sale del campo que añadiste al
        //     proceso de compra.
        $contacto = cairos_llamar("/contactos/upsert", [
            "nombre"     => trim($pedido->get_billing_company()
                ?: $pedido->get_formatted_billing_full_name()),
            "nif"        => $pedido->get_meta("_billing_nif"),
            "email"      => $pedido->get_billing_email(),
            // "customer" o "supplier": el enumerado esta en
            // ingles y un "cliente" devuelve 422.
            "tipo"       => "customer",
            "buscar_por" => "email",
        ], $llave . "-contacto");

        // 3 · Líneas. get_subtotal() es SIN impuestos, que es lo
        //     que espera la API por defecto. El campo del texto
        //     es "descripcion" y el del impuesto "tipo_iva":
        //     "concepto" e "iva" no existen y dan 422.
        $lineas = [];
        foreach ($pedido->get_items() as $item) {
            $cantidad = $item->get_quantity();
            $lineas[] = [
                "descripcion" => $item->get_name(),
                "cantidad"    => $cantidad,
                "precio"      => round($item->get_subtotal() / max(1, $cantidad), 2),
                "tipo_iva"    => 21,
            ];
        }

        // 4 · Borrador. La fecha es "fecha_emision".
        $factura = cairos_llamar("/facturas", [
            "contacto_id"   => $contacto["id"],
            "serie"         => "WEB",
            "fecha_emision" => $pedido->get_date_paid()->date("Y-m-d"),
            "lineas"        => $lineas,
        ], $llave);

        // 5 · Guardamos ANTES de emitir. Si algo falla después,
        //     al menos sabemos que este pedido ya tiene factura.
        $pedido->update_meta_data("_cairos_factura_id", $factura["id"]);
        $pedido->save();

        // 6 · Emitir: aquí es donde toma número de serie.
        $emitida = cairos_llamar(
            "/facturas/" . $factura["id"] . "/emitir",
            null,
            $llave . "-emitir"
        );

        // 7 · Y el cobro, que en una tienda ya ha ocurrido.
        //     "metodo" es un enumerado cerrado: transfer, cash,
        //     card, direct_debit u other. El nombre bonito de la
        //     pasarela va en "notas", que es texto libre.
        cairos_llamar("/cobros", [
            "factura_id" => $factura["id"],
            "fecha"      => $pedido->get_date_paid()->date("Y-m-d"),
            "importe"    => (float) $pedido->get_total(),
            "metodo"     => "card",
            "notas"      => $pedido->get_payment_method_title(),
        ], $llave . "-cobro");

        // "referencia" es lo que sale impreso ("WEB-0042").
        // "numero" es un entero y por si solo no dice la serie.
        $pedido->update_meta_data("_cairos_factura_ref", $emitida["referencia"]);
        $pedido->add_order_note("Factura " . $emitida["referencia"]);
        $pedido->save();

    } catch (Exception $e) {
        // Ni una excepción sin registrar: un fallo silencioso aquí
        // se descubre en el cierre del trimestre.
        $pedido->add_order_note("Cairos: " . $e->getMessage());
        error_log("Cairos: " . $e->getMessage());
    }
}

The five details you cannot see in the code

  1. The key, in an environment variable. In the code above it is read with getenv. If you put it in the plugin file, it travels to your repository and into every backup. And it certainly does not go in the WordPress database in a visible options field.
  2. The tipo_iva, from the line and not fixed. The example puts 21 so that it reads clearly. If you sell books, food or services at different rates, take the rate from the taxes on each order line; putting 21 on everything is an accounting error, not a programming one. And if your company is in the Canary Islands, the rate that applies is the IGIC one: GET /organizacion tells you which territory you are in, in region_fiscal.
  3. If your shop works with prices that include VAT, do not break it out yourself. Send the prices on the label and add "precios_con_iva" => true to the invoice body: Cairos works the base back out in whole cents, with the same engine the shop connector uses. A hand-written breakdown is almost always done forwards, and that missing cent piles up invoice after invoice until the 303 no longer matches the till.
  4. Shipping costs are invoiced too. They are not in get_items(): they have to be added as one more line, with their VAT rate, which in Spain is that of the main good.
  5. Refunds are not settled here. A refund in WooCommerce does not delete an invoice already issued: it needs a corrective invoice. Today that is done from Cairos, by hand. It is only honest to say so before somebody builds a shop counting on it sorting itself out.

How to test it without messing up your invoicing

With the cai_test_ key, which works on your own data but does not register with VeriFactu and does not send emails. And with a separate invoice series — PRUEBAS, say — so that the numbers from your tests do not get mixed in with those of your real series.

Then a real one-euro order with your own card, and check three things: that the invoice appears, that the number is saved on the order, and that firing the hook again does not create a second one. That third one is the one that really has to be tested, and you test it by calling the function twice by hand.

Questions about WooCommerce

No. It does not exist in the WordPress repository or anywhere else, and we are not going to suggest that it does. What there is is the API and this guide with the hook written out.
When the order is paid, with woocommerce_payment_complete, or when it is completed if you sell physical goods. Never when the order is created: a cart with the payment gateway open may never be paid at all.
With two things at once: saving the invoice identifier on the order and checking it on the way in, and sending Idempotency-Key: wc-1042. The first prevents reprocessing; the second, a dropped connection. It is explained in idempotency.
Adding it to the checkout, with a Spanish invoicing add-on or with the billing fields hook. Without a NIF you can only issue a simplified invoice, which has a cap on the amount and which your business customer cannot deduct.
A refund in WooCommerce does not delete the invoice: it needs a corrective invoice, and today that is issued from Cairos by hand. It is the part that is not automated yet.

This code works today: create the key and test it

It is written against the field names the server really accepts. With a test key and a separate series you can fire it twenty times without messing anything up.

PHP with no dependencies · Idempotency-Key · A separate test series

Support