Aller au contenu
Créer un compteS'inscrire

L'API de caisse.bzh

Afficher la carte d'un établissement sur votre site, lui transmettre des commandes à emporter, relier vos outils à son catalogue, ses ventes et ses commandes, tenir le même stock entre une boutique en ligne et la caisse. Tout est décrit dans une définition OpenAPI et utilisable avec un SDK JavaScript sans dépendance.

Ressources

Démarrer

L'adresse de base est https://caisse.bzh. Les réponses sont en JSON et portent toujours ok : true en cas de succès, sinon false avec un code d'erreur. Montants en euros TTC.

Chaque appel porte un User-Agent qui nomme votre outil, par exemple ma-boutique-sync/1.0. Le pare-feu de caisse.bzh écarte les robots d'analyse et les scripts anonymes : les User-Agent par défaut des bibliothèques HTTP de Python (urllib, requests), Go et Java reçoivent un 403 blocked. curl, PHP, Node et les navigateurs conviennent tels quels.

# Python (requests)
requests.get(url, headers={"User-Agent": "ma-boutique-sync/1.0"})

// Go
req.Header.Set("User-Agent", "ma-boutique-sync/1.0")

// Java (java.net.http)
HttpRequest.newBuilder(uri).header("User-Agent", "ma-boutique-sync/1.0")

Pour essayer sans compte, l'établissement de démonstration a pour identifiant comptoir-brest :

curl 'https://caisse.bzh/api/carte?slug=comptoir-brest'

Carte et commandes

Deux appels publics, sans authentification, pour les métiers qui ont une carte en ligne (restaurant, bar). L'identifiant d'un établissement est celui de l'adresse de sa carte, caisse.bzh/carte/<slug>.

AppelRôle
GET/api/carte?slug=Identité, horaires, ouverture à l'instant, catégories et articles avec leur id.
POST/api/orderCommande à emporter, payée au retrait. Elle arrive dans « Commandes en ligne » ; l'équipe l'accepte puis la marque prête.
curl -X POST https://caisse.bzh/api/order \
  -H 'content-type: application/json' \
  -d '{"slug":"comptoir-brest","customerName":"Léna","phone":"06 12 34 56 78",
       "pickupAt":"12:30","items":[{"itemId":"en2","qty":2}]}'

La caisse recalcule toujours les prix et le total à partir de sa carte : un montant envoyé est ignoré et une ligne dont l'article est inconnu est retirée. 40 lignes au plus, 20 exemplaires au plus par ligne. Une commande passée en dehors des horaires est acceptée comme précommande.

La carte se lit depuis n'importe quel navigateur. La commande s'envoie depuis votre serveur : l'appel n'est pas ouvert aux navigateurs d'autres sites, et il est limité à 60 commandes par tranche de 10 minutes et par établissement.

import { createClient } from "https://caisse.bzh/sdk/caisse-bzh.mjs";

const caisse = createClient(); // sans clé
const carte = await caisse.getCarte("comptoir-brest");

const commande = await caisse.placeOrder({
  slug: "comptoir-brest",
  customerName: "Léna",
  pickupAt: "12:30",
  items: [{ itemId: carte.items[0].id, qty: 2 }],
});
console.log("Commande n°" + commande.no, commande.total + " €");

Clés API

Pour lire et modifier les données d'un établissement depuis vos propres outils (export comptable, tableau de bord, borne, site), créez une clé dans la console : Réglages › Accès API. Le gérant et les managers peuvent créer et révoquer des clés, pas les serveurs, caissiers ni la cuisine.

  • Une clé vaut pour un seul établissement : celui qui était choisi à sa création.
  • Lecture seule ou lecture et écriture (modifier le catalogue, faire avancer les commandes).
  • Elle ne s'affiche qu'une fois. caisse.bzh n'en garde qu'une empreinte : une clé perdue se remplace, elle ne se retrouve pas.
  • Elle s'envoie dans l'en-tête Authorization: Bearer cbz_live_…, depuis un serveur seulement : jamais dans une page web ou une application installée.
  • Révoquée depuis la console, elle cesse de fonctionner aussitôt. 120 appels par minute et par clé ; au-delà, 429 avec Retry-After.
curl https://caisse.bzh/api/v1/sales?from=2026-09-01 \
  -H 'authorization: Bearer cbz_live_…'

API avec clé

AppelRôle
GET/api/v1/shopL'établissement de la clé, ses fonctions (commandes en ligne, stock) et les droits de la clé.
GET/api/v1/catalogueCatégories (avec leur TVA) et articles, déclinaisons et options comprises.
POST/api/v1/catalogue/itemsCréer un article : name, price TTC, categoryId, et au choix description, tags, sku, barcode.
PATCH/api/v1/catalogue/items/<id>Modifier les champs envoyés.
DELETE/api/v1/catalogue/items/<id>Supprimer un article.
GET/api/v1/stockQuantités des articles reçus d'une boutique en ligne (commerces).
GET/api/v1/salesTickets encaissés : lignes, TVA par taux, moyens de paiement. Filtres from, to, limit.
GET/api/v1/ordersCommandes en ligne, filtre status : new, accepted, ready, collected, cancelled (restaurants et bars).
POST/api/v1/orders/<id>/acceptAussi ready, collected, cancel. Accepter envoie la commande à l'écran cuisine.

Un article reçu d'une boutique en ligne porte managedBy: "sync" : il se modifie sur la boutique, la caisse refuse de le changer (409 managed_by_sync). Les ventes du mode formation portent training: true et ne comptent pas dans le chiffre d'affaires ; pour l'administration fiscale, c'est le journal scellé de la console qui fait foi.

import { createApiClient } from "./caisse-bzh.mjs";

const api = createApiClient({ apiKey: process.env.CAISSE_API_KEY });

// les ventes de septembre, pour la comptabilité
const { sales } = await api.listSales({ from: "2026-09-01", to: "2026-09-30", limit: 1000 });

// un nouvel article sur la carte
const { categories } = await api.getCatalogue();
await api.createItem({ name: "Galette complète", price: 11.5, categoryId: categories[0].id });

// les commandes à préparer
for (const o of await api.listOrders({ status: "new" })) await api.updateOrder(o.id, "accept");

Synchronisation catalogue et stock

Le protocole qu'emploie l'extension Inklura Sync pour WooCommerce, ouvert à toute boutique en ligne. La boutique possède le catalogue : elle envoie ses articles et ses mouvements de stock, la caisse renvoie ce qu'elle a vendu ou remboursé. Les articles créés à la main en caisse ne sont jamais touchés.

  1. Dans la console, ouvrez Boutique en ligne : vous y trouvez l'adresse /api/sync?shop=<jeton> et le secret partagé.
  2. Indiquez l'adresse HTTPS de votre webhook et activez l'échange.
  3. Envoyez vos articles, puis vos mouvements de stock, en appels signés.

Chaque appel est un POST JSON signé :

corps     = le JSON envoyé, tel quel
timestamp = secondes Unix (à moins de 5 minutes de l'heure de la caisse)
nonce     = 16 octets aléatoires en hexadécimal (32 caractères)
signature = HMAC-SHA256(secret, "1\n" + timestamp + "\n" + nonce + "\n" + corps), en hexadécimal

En-têtes : x-wd29-timestamp, x-wd29-nonce, x-wd29-signature

Sans signature valide, la réponse est 401 et rien n'est lu ni écrit. Le corps porte version: 1, source: "woo" (le côté boutique du protocole, quelle que soit votre plateforme) et une opération :

opEffet
healthÉtat de la liaison et file d'attente sortante. Répond même quand l'échange est coupé.
eventsJusqu'à 20 événements : product (instantané complet d'un article), stock (delta ou quantité fixée).
tickRéveille la caisse, qui renvoie aussitôt les mouvements en attente.

Les clés d'articles sont celles de votre boutique, woo:product:<id> et woo:variant:<id> avec un id entier. Chaque événement porte un id de 32 caractères hexadécimaux : un événement renvoyé deux fois n'est appliqué qu'une fois. Les quantités d'un instantané ne servent qu'au départ ; ensuite seuls les événements stock font bouger le compte.

import { createSyncClient } from "https://caisse.bzh/sdk/caisse-bzh.mjs";

const sync = createSyncClient({
  token: process.env.CAISSE_TOKEN,   // jeton de la boutique
  secret: process.env.CAISSE_SECRET, // secret partagé, jamais dans un navigateur
});

await sync.health(); // { ok: true, mode: "live", ... }

await sync.upsertProduct({
  key: "woo:product:408",
  name: "Pull marin",
  status: "publish",
  categories: [["Vêtements", "Pulls"]],
  prices: { regular: 89, tax_rate: 20 },
  identifiers: { ean13: "3760000000017" },
  variants: [
    { key: "woo:variant:409", status: "publish", attributes: { taille: "M" } },
    { key: "woo:variant:410", status: "publish", attributes: { taille: "L" } },
  ],
  inventory: [{ key: "woo:variant:409", quantity: 6 }, { key: "woo:variant:410", quantity: 4 }],
});

await sync.adjustStock("woo:variant:409", -1); // une vente sur votre site

Recevoir les ventes de la caisse

Quand la caisse vend ou rembourse un article synchronisé, elle appelle votre webhook avec la même signature, un événement à la fois et dans l'ordre (source: "ps", delta négatif pour une vente). Répondez {"ok": true} avec un statut 2xx ; sinon elle réessaie plus tard sans changer l'ordre. Vérifiez la signature avant de lire le corps, et n'appliquez pas deux fois le même id.

import { verifySignature } from "https://caisse.bzh/sdk/caisse-bzh.mjs";

// votre webhook, par exemple POST /caisse
export async function POST(request) {
  const body = await request.text(); // le corps brut, avant JSON.parse
  let msg;
  try {
    msg = await verifySignature({ secret: process.env.CAISSE_SECRET, body, headers: request.headers });
  } catch {
    return new Response("{\"ok\":false}", { status: 401 });
  }
  if (msg.op === "events") {
    for (const e of msg.events) {
      // e.key = "woo:variant:409", e.payload.delta = -1 (vente) ou +1 (remboursement)
      await appliquerUneFois(e.id, e.key, e.payload.delta);
    }
  }
  return Response.json({ ok: true });
}

SDK JavaScript

Un seul fichier, sans dépendance, fondé sur fetch et WebCrypto. Les erreurs sont levées en CaisseApiError avec status et code.

# Node 20+, Bun : copie locale, avec ses types
curl -O https://caisse.bzh/sdk/caisse-bzh.mjs
curl -O https://caisse.bzh/sdk/caisse-bzh.d.ts
import { createClient } from "./caisse-bzh.mjs";

# Deno, navigateur : import direct
import { createClient } from "https://caisse.bzh/sdk/caisse-bzh.mjs";
FonctionRôle
createApiClient()getShop(), getCatalogue(), createItem(), updateItem(), deleteItem(), getStock(), listSales(), listOrders(), updateOrder()
createClient()getCarte(slug), placeOrder(commande)
createSyncClient()health(), upsertProduct(), removeProduct(), adjustStock(), setStock(), sendEvents(), tick()
verifySignature()Contrôle un appel reçu de la caisse et renvoie le message.
signedHeaders()Les trois en-têtes de signature, pour un autre langage ou un client maison.

Dans un autre langage, générez un client depuis openapi.json ; seule la signature est à écrire à la main, en quelques lignes.

PHP et Python

La signature tient en quelques lignes dans tous les langages. Ces deux fonctions envoient un message à la caisse et vérifient un appel reçu d'elle ; elles ont été essayées telles quelles contre caisse.bzh.

L'exemple Python fixe son User-Agent, comme le demande la règle vue dans Démarrer.

// Appel signé vers la caisse (synchronisation), PHP 7.4+ avec ext-curl.
function caisse_sync(string $token, string $secret, array $message): array {
    $body = json_encode(['version' => 1, 'source' => 'woo'] + $message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    $timestamp = (string) time();
    $nonce = bin2hex(random_bytes(16));
    $signature = hash_hmac('sha256', "1\n$timestamp\n$nonce\n$body", $secret);
    $ch = curl_init('https://caisse.bzh/api/sync?shop=' . $token);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $body,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            'Content-Type: application/json',
            "X-Wd29-Timestamp: $timestamp",
            "X-Wd29-Nonce: $nonce",
            "X-Wd29-Signature: $signature",
        ],
    ]);
    $answer = json_decode((string) curl_exec($ch), true);
    curl_close($ch);
    return is_array($answer) ? $answer : ['ok' => false];
}

// Vérifier un appel reçu de la caisse, avant de lire le corps.
function caisse_verify(string $secret, string $body, array $headers): ?array {
    $ts = $headers['x-wd29-timestamp'] ?? '';
    $nonce = $headers['x-wd29-nonce'] ?? '';
    $sig = $headers['x-wd29-signature'] ?? '';
    if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300) return null;
    if (!preg_match('/^[a-f0-9]{32}$/', $nonce) || !preg_match('/^[a-f0-9]{64}$/', $sig)) return null;
    $expected = hash_hmac('sha256', "1\n$ts\n$nonce\n$body", $secret);
    if (!hash_equals($expected, $sig)) return null;
    $msg = json_decode($body, true);
    return is_array($msg) && ($msg['version'] ?? null) === 1 ? $msg : null;
}
# Appel signé vers la caisse (synchronisation), Python 3.10+ sans dépendance.
import hashlib, hmac, json, secrets, time, urllib.request

def caisse_sync(token: str, secret: str, message: dict) -> dict:
    body = json.dumps({"version": 1, "source": "woo", **message}, ensure_ascii=False, separators=(",", ":"))
    timestamp = str(int(time.time()))
    nonce = secrets.token_hex(16)
    signature = hmac.new(secret.encode(), f"1\n{timestamp}\n{nonce}\n{body}".encode(), hashlib.sha256).hexdigest()
    req = urllib.request.Request(
        "https://caisse.bzh/api/sync?shop=" + token,
        data=body.encode(),
        method="POST",
        headers={
            "Content-Type": "application/json",
            "User-Agent": "ma-boutique-sync/1.0",  # nommez votre outil (voir Démarrer)
            "X-Wd29-Timestamp": timestamp,
            "X-Wd29-Nonce": nonce,
            "X-Wd29-Signature": signature,
        },
    )
    try:
        with urllib.request.urlopen(req) as res:
            return json.load(res)
    except urllib.error.HTTPError as err:
        return json.load(err)

def caisse_verify(secret: str, body: bytes, headers) -> dict | None:
    ts, nonce, sig = headers.get("x-wd29-timestamp", ""), headers.get("x-wd29-nonce", ""), headers.get("x-wd29-signature", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300 or len(nonce) != 32 or len(sig) != 64:
        return None
    expected = hmac.new(secret.encode(), b"1\n" + ts.encode() + b"\n" + nonce.encode() + b"\n" + body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig):
        return None
    msg = json.loads(body)
    return msg if msg.get("version") == 1 else None

Nouveaux essais et doublons

Un appel qui crée quelque chose (POST /api/order, POST /api/v1/catalogue/items) accepte un en-tête Idempotency-Key, par exemple un UUID tiré pour chaque commande. Si la réponse se perd et que vous renvoyez la même requête avec la même clé dans les 24 heures, vous recevez la première réponse (en-tête Idempotent-Replayed: true) : pas de seconde commande en cuisine, pas d'article en double.

  • Même clé pour une requête différente : 422 idempotency_key_reused.
  • Requête identique encore en cours : 409 idempotency_in_progress, réessayez un peu plus tard.
  • Une erreur 5xx, 409 ou 429 n'est pas retenue : le nouvel essai s'exécute vraiment.
  • Les autres erreurs (400, 404, 422…) sont rejouées telles quelles pendant 24 heures : corrigez la requête et tirez une nouvelle clé.

Dans le SDK : placeOrder(commande, { idempotencyKey }) et createItem(article, { idempotencyKey }).

Erreurs et limites

StatutCodeCause
400no_slug, bad_json, no_name, no_itemsRequête incomplète ou aucune ligne reconnue.
429rate_limitedTrop de commandes pour cet établissement en 10 minutes.
404unknown_shopÉtablissement inconnu ou sans carte publique.
401unauthorizedClé API absente, inconnue ou révoquée.
403read_only_keyÉcriture avec une clé en lecture seule.
409managed_by_sync, last_itemArticle tenu par la boutique en ligne ; dernier article d'une carte.
403blockedUser-Agent par défaut d'une bibliothèque HTTP (Python, Go, Java) : nommez votre outil (voir Démarrer).
429rate_limitedPlus de 120 appels dans la minute avec la même clé.
401bridge_authJeton inconnu, signature fausse ou horodatage trop ancien.
400bridge_requestMessage invalide ; message: "disabled" si l'échange est coupé dans la console.
503bridge_requestStock modifié au même instant ailleurs : renvoyez le même événement.

Corps de 4 Mo au plus pour la synchronisation. La carte est mise en cache une minute.

Un besoin que l'API ne couvre pas encore ? Écrivez-nous : bonjour@caisse.bzh.