Ingénierie
Comment un devis accepté devient exactement une commande provisoire Shopify
Par Jahangir Alam · 30 septembre 2026 · 13 min de lecture
- Dernière vérification
- API Shopify
- 2026-07
- Public
- Développeurs et agences qui construisent ou auditent une intégration devis vers commande provisoire sur Shopify
- Périmètre
- Rendre draftOrderCreate idempotent alors que Shopify ne propose aucune clé d'idempotence en API 2026-07 : un enregistrement par conversion, une entrée stockée, une réservation en base avec une fenêtre de péremption, une transaction de succès unique avec une outbox transactionnelle, un traitement idempotent d'orders/create, la réconciliation, et pourquoi une création à l'issue inconnue n'est jamais relancée automatiquement
En version 2026-07 de l'Admin API Shopify, draftOrderCreate ne prend aucune clé d'idempotence. Certaines mutations en ont une - inventoryAdjustQuantities a gagné une clé @idempotent facultative en 2026-01 et l'a rendue obligatoire en 2026-04, et sa page de référence le dit - mais la mutation des commandes provisoires ne documente rien de tel. Appelez-la deux fois avec la même entrée et vous avez deux commandes provisoires, deux factures, et un acheteur qui paie l'une et conteste l'autre.
Une application qui transforme des devis acceptés en commandes provisoires doit donc rendre la conversion idempotente elle-même. Cet article montre comment nous l'avons construit dans QuotWay : un enregistrement par conversion, l'entrée calculée exacte stockée et renvoyée telle quelle, un claim (réservation) en base pour qu'un seul worker appelle jamais Shopify, le résultat enregistré dans une seule transaction avec son événement sortant, un handler de webhook idempotent pour la commande qui revient, et un balayage qui récupère ce que les webhooks manquent - mais qui refuse délibérément de relancer le seul cas dont il ne peut rien voir.
Les modes de défaillance autour de la conversion - dérive de taxe, dérive de prix, stock - sont traités dans ce qui casse entre le moment du devis et celui de la conversion. Cet article ne porte que sur l'un d'eux : s'assurer que la conversion a lieu exactement une fois.
Les faits Shopify ont été vérifiés sur les pages de référence de Shopify le 30 septembre 2026, en version d'API 2026-07. Le code est simplifié à partir des services de conversion de QuotWay : noms raccourcis, identifiants internes retirés.
D'où viennent les doublons
Tout ce qui peut appeler draftOrderCreate deux fois finira par le faire :
- La file de tâches relance. Une création qui expire côté appelant a pu réussir côté Shopify. La file voit un échec et réessaie.
- Deux workers prennent la même tâche. Les files « au moins une fois » livrent une tâche deux fois sous charge ou pendant un déploiement.
- Quelqu'un clique deux fois. « Convertir en commande provisoire » sur un réseau lent.
- L'automatisation et une personne agissent en même temps. Une règle de conversion automatique se déclenche pendant qu'un membre de l'équipe convertit à la main.
- Le processus meurt entre la réponse de Shopify et votre écriture en base. Le brouillon existe ; votre enregistrement dit le contraire.
Les quatre premiers sont des courses entre tentatives. Le cinquième est différent : une tentative dont vous ignorez l'issue. La conception doit gérer les deux, et ils appellent des réponses différentes.
Un enregistrement par conversion
Chaque conversion est une ligne, créée avant l'appel à Shopify, unique par groupe de conversion - l'ensemble des lignes acceptées qui deviendront une commande provisoire. Un devis accepté en entier a un groupe ; un devis accepté en partie et converti en deux fois en a deux, et chacun devient sa propre commande provisoire. La ligne contient :
| Champ | Pourquoi il est là |
|---|---|
| Une clé construite à partir du devis, du groupe et de la version | Unique ; une seconde tentative trouve la première |
Le DraftOrderInput exact qui a été calculé |
Renvoyé tel quel à chaque tentative |
| Un horodatage de réservation | Qui appelle Shopify en ce moment, et depuis quand |
| L'identifiant de la commande provisoire Shopify | Une fois renseigné, la conversion est faite |
| L'identifiant de la commande Shopify | Renseigné quand l'acheteur paie |
| Le nombre d'erreurs et la dernière erreur | Ce qu'une personne voit quand la conversion est bloquée |
Étape 1 : calculer une fois, envoyer la même entrée
Avant toute création, la conversion est calculée : les lignes, les prix, la remise, la livraison, les conditions et l'acheteur (sous forme de purchasingEntity pour un devis rattaché à une entreprise) sont assemblés en un DraftOrderInput, vérifiés auprès de Shopify avec draftOrderCalculate, et le marchand examine tout écart. Cette entrée est stockée sur la ligne de conversion - l'objet exact, pas les ingrédients pour le reconstruire.
Chaque tentative de création envoie cet objet stocké. Une relance qui recalculerait l'entrée pourrait produire un brouillon que le marchand n'a jamais approuvé - un prix catalogue qui a bougé pendant la nuit, un montant de taxe qui a changé - et c'est un bug de justesse même quand ce n'est pas un doublon.
Étape 2 : court-circuiter, puis réserver
La création s'exécute comme une tâche en file. Ses deux premiers gestes décident si elle appelle Shopify ou non :
// 1. Already done? A retry or a double-click lands here.
if (conversion.draftOrderId) return { conversion, isExisting: true };
// 2. Claim it. Exactly one worker's conditional update matches.
const CLAIM_STALE_MS = 2 * 60 * 1000;
const claimedAt = new Date();
const claim = await db.conversion.updateMany({
where: {
id: conversion.id,
draftOrderId: null,
OR: [
{ claimedAt: null },
{ claimedAt: { lt: new Date(claimedAt.getTime() - CLAIM_STALE_MS) } },
],
},
data: { claimedAt },
});
if (claim.count === 0) {
// Lost the race. If the winner already stored the draft, that's our answer;
// otherwise it's still mid-call - throw, and the queue retries in a moment.
const fresh = await db.conversion.findUnique({ where: { id: conversion.id } });
if (fresh?.draftOrderId) return { conversion: fresh, isExisting: true };
throw new ConversionInProgressError(conversion.id);
}
Deux choses rendent cela sûr. La décision est prise par la base de données, en une seule instruction conditionnelle, si bien que deux workers ne peuvent pas lire tous les deux « non réservée » et continuer tous les deux - une mise à jour correspond, l'autre ne correspond à rien. Et la réservation est un horodatage, pas un verrou : si le worker qui la détient meurt, la réservation devient périmée au bout de deux minutes au lieu de bloquer la conversion pour toujours.
Le perdant n'échoue pas. Si le gagnant a déjà stocké l'identifiant de la commande provisoire, le perdant le renvoie comme un succès ; sinon, il lève une erreur spécifique « en cours » et la file réessaie peu après, moment où il trouve généralement le résultat terminé.
Étape 3 : appeler Shopify, enregistrer le résultat en une transaction
Le worker qui détient la réservation envoie l'entrée stockée à draftOrderCreate. Si Shopify renvoie des erreurs utilisateur ou si l'appel échoue, le worker enregistre l'erreur, incrémente le nombre d'erreurs, libère la réservation pour que la relance n'attende pas deux minutes, et marque la conversion comme échouée. La file relance un petit nombre de fois avec un délai croissant ; au-delà, une personne voit l'erreur.
Si Shopify renvoie une commande provisoire, tout ce qui suit se passe dans une seule transaction en base :
await db.$transaction(async (tx) => {
await tx.conversion.update({
where: { id: conversion.id },
data: { draftOrderId: draft.id, draftOrderName: draft.name, invoiceUrl: draft.invoiceUrl, lastError: null },
});
await tx.conversionGroup.update({ where: { id: group.id }, data: { state: "CONVERTED" } });
await advanceQuoteState(tx, quote); // partially -> fully converted when the last group lands
// The outbound event is written in the SAME transaction (a transactional outbox).
await tx.integrationEvent.create({
data: { quoteId: quote.id, type: "quote.converted", data: { draftOrderId: draft.id } },
});
});
await dispatchIntegrationEvents(); // after commit, best effort; a 15-minute cron sends anything still pending
L'outbox compte autant que l'identifiant. L'événement quote.converted qui atteint les webhooks et Shopify Flow est enregistré dans la même transaction que le changement d'état, puis envoyé après le commit : c'est une « outbox transactionnelle ». Un événement envoyé avant le commit pourrait annoncer une conversion qui a été annulée ; un événement envoyé après le commit depuis la mémoire est perdu si le processus meurt entre les deux. Écrit dans la transaction, il existe exactement quand la conversion existe.
Étape 4 : la commande revient par un webhook
La commande provisoire n'est pas la fin. Quand l'acheteur paie, Shopify crée une commande et envoie orders/create - au moins une fois, parfois plus d'une fois, occasionnellement jamais, et sans garantie de délai. La commande porte un attribut de note qui nomme le groupe de conversion, et c'est ainsi que le handler retrouve son chemin.
Trois couches la gardent idempotente :
- Chaque livraison de webhook est enregistrée, unique par boutique et par identifiant de webhook Shopify. Une nouvelle livraison est une insertion en double, et la contrainte d'unicité transforme une paire concurrente en un gagnant et un « déjà vu » - pas de course entre vérification et insertion.
- Le handler lui-même est idempotent. Si la conversion a déjà un identifiant de commande, une seconde réception est un no-op.
- Le webhook peut devancer notre propre écriture. Un acheteur qui paie quelques secondes après avoir reçu la facture peut déclencher
orders/createavant que la ligne de conversion soit validée en base. Le handler ne traite pas une ligne absente comme « pas à nous » - l'attribut de note dit le contraire - il planifie donc une relance différée et réessaie.
const groupId = readNoteAttribute(order.note_attributes, "conversion_group_id");
if (!groupId) return { kind: "not-ours" };
const conversion = await db.conversion.findUnique({ where: { groupId } });
if (!conversion) {
await enqueueDelayedRetry({ order, shopId }); // our own write hasn't landed yet
return { kind: "retry-later" };
}
if (conversion.orderId) return { kind: "already-linked" }; // Shopify redelivered
await linkOrderAndAdvanceQuote(db, conversion, order);
Étape 5 : le balayage
Une tâche planifiée tourne toutes les 15 minutes et comble les trous que les événements ne peuvent pas combler :
| Condition | Ce que fait le balayage |
|---|---|
| Facture envoyée il y a plus de 7 jours, identifiant de brouillon stocké, pas d'identifiant de commande | Interroge Shopify sur la commande provisoire. Si elle a une commande, l'acheteur a payé et le webhook a été manqué : rejoue la commande par le même handler que celui du webhook, pour que le chemin de rattrapage soit le chemin testé |
| Calculée il y a plus de 24 heures, jamais créée | La signale ; le marchand décide - elle attend peut-être simplement |
| Calculée, pas d'identifiant de brouillon, inchangée depuis plus de 5 minutes | La signale à une personne. Ne relance pas. |
Chaque branche est plafonnée par exécution, et les requêtes à Shopify partent quelques-unes à la fois, si bien qu'un arriéré est reporté à l'exécution suivante au lieu de faire exploser le budget de temps de la tâche.
Pourquoi le balayage ne relance pas l'inconnu
La dernière ligne est celle que les développements sur mesure sautent. Une conversion calculée, réservée, puis devenue silencieuse sans identifiant de brouillon a deux histoires possibles : l'appel n'a jamais atteint Shopify, ou Shopify a créé le brouillon et le processus est mort avant de l'enregistrer. Vues depuis la base de données, elles sont identiques. Sans clé d'idempotence sur draftOrderCreate, relancer la seconde histoire crée une seconde commande provisoire - et une seconde facture que l'acheteur peut payer.
Le balayage la fait donc remonter. La résolution est une recherche, pas une relance : chercher dans les commandes provisoires de la boutique la référence de la conversion (les brouillons convertis par QuotWay portent quotway et un tag de référence du devis, ainsi que l'attribut de note du groupe de conversion), adopter le brouillon s'il existe, et n'en créer un que s'il n'existe pas. Cette étape demande une personne aujourd'hui parce qu'elle est rare et que se tromper coûte de l'argent ; si Shopify ajoute @idempotent à draftOrderCreate, la clé ira sur l'appel et toute cette branche deviendra une relance ordinaire.
Ce qui se passe si le processus meurt à chaque étape
| Le processus meurt… | État laissé derrière | Ce qui se passe ensuite |
|---|---|---|
| Avant de réserver | Calculée, non réservée | La file relance ; la tentative suivante réserve normalement |
| Après la réservation, avant l'appel à Shopify | Réservée, pas d'identifiant de brouillon | La réservation devient périmée au bout de 2 minutes ; la relance de la file la reprend et appelle Shopify une fois |
| Pendant l'appel, Shopify ne l'a jamais reçu | Réservée, pas d'identifiant de brouillon | Comme ci-dessus - mais impossible à distinguer de la ligne suivante |
| Après que Shopify a créé le brouillon, avant la transaction | Réservée, pas d'identifiant de brouillon, le brouillon existe dans Shopify | Le balayage la signale au bout de 5 minutes ; une personne retrouve le brouillon par sa référence et l'adopte |
| Après la transaction, avant l'envoi de l'outbox | Convertie, événement en attente | La prochaine exécution du worker l'envoie - un cron de 15 minutes sert de filet, si bien qu'un signal manqué retarde l'événement sans jamais le perdre |
| Pendant que le webhook de commande est en route | Convertie, pas d'identifiant de commande | Shopify relivre ; ou le balayage à 7 jours interroge Shopify et rejoue la commande |
Une check-list pour votre propre développement
- Un enregistrement de conversion par future commande provisoire, unique, créé avant tout appel à Shopify.
- Stocker le
DraftOrderInputcalculé exact ; le renvoyer, ne jamais le recalculer. - Réserver par une mise à jour conditionnelle avec une fenêtre de péremption - pas par un verrou en mémoire.
- Libérer la réservation sur un échec franc, pour que les relances n'attendent pas.
- Enregistrer l'identifiant du brouillon, les changements d'état et les événements sortants dans une seule transaction ; envoyer les événements après le commit.
- Dédoublonner les webhooks sur l'identifiant de webhook Shopify avec une contrainte d'unicité, et rendre aussi le handler idempotent.
- Traiter « webhook arrivé avant ma propre écriture » comme une relance, pas comme « pas à moi ».
- Réconcilier en interrogeant Shopify, et rejouer par le même handler.
- Ne jamais relancer automatiquement une création dont vous ne voyez pas l'issue ; chercher d'abord le brouillon.
- Relire la page de référence de
draftOrderCreateà chaque version d'API - si@idempotentapparaît, l'utiliser.
Les mêmes questions, posées à un éditeur plutôt qu'à votre propre code, se trouvent dans comment évaluer une application Shopify B2B, et les champs que porte une commande provisoire convertie - tags, note, attributs sur lesquels un ERP peut s'appuyer - sont dans les modèles d'intégration ERP, CRM et PIM.
Où cela vit dans QuotWay
Tout ce qui précède est le chemin de conversion de QuotWay tel qu'il tourne aujourd'hui, sur tous les forfaits : un devis accepté, ou chaque partie acceptée d'un devis, devient une commande provisoire Shopify, et l'événement quote.converted est émis une fois par commande provisoire - vers Shopify Flow à partir du forfait Professional, et vers les webhooks et l'API sur Enterprise. Les forfaits sont sur la page des tarifs.
Questions fréquentes
draftOrderCreate de Shopify prend-il en charge les clés d'idempotence ?
Pas en version d'API 2026-07 : sa page de référence ne documente aucune directive @idempotent ni aucun argument d'idempotence. Les mutations qui la prennent en charge le disent sur leur page de référence - inventoryAdjustQuantities, par exemple, a rendu sa clé facultative en 2026-01 et obligatoire en 2026-04. Vérifiez la page des commandes provisoires à chaque version trimestrielle.
Comment éviter les commandes provisoires en double quand une tâche est relancée ?
Gardez un enregistrement par conversion, créé avant l'appel, et laissez exactement un worker le réserver par une mise à jour conditionnelle en base. Une relance qui trouve un identifiant de commande provisoire stocké le renvoie ; une relance qui perd la réservation attend et réessaie. Renvoyez l'entrée exacte que vous avez calculée, pour qu'une relance ne puisse pas non plus créer un brouillon différent.
Et si mon processus plante après que Shopify a créé la commande provisoire ?
Votre enregistrement montre une réservation mais pas d'identifiant de brouillon, et vous ne pouvez pas savoir si Shopify a reçu l'appel. Ne relancez pas à l'aveugle - c'est le seul chemin vers un vrai doublon. Cherchez dans les commandes provisoires de la boutique la référence que vous y avez attachée (un tag ou un attribut de note), adoptez le brouillon s'il est là, et n'en créez un que s'il n'y est pas.
Comment gérer les webhooks orders/create en double ?
Enregistrez chaque livraison avec l'identifiant de webhook Shopify sous une contrainte d'unicité, pour qu'une nouvelle livraison soit rejetée comme doublon même quand deux copies arrivent en même temps. Puis rendez le handler lui-même idempotent : si la commande est déjà liée, ne faites rien.
Et si le webhook orders/create arrive avant ma propre écriture en base ?
C'est possible, quand un acheteur paie quelques secondes après la facture. Si la commande porte votre référence mais que votre enregistrement n'existe pas encore, planifiez une relance différée au lieu de l'écarter comme « pas à nous ».
Et si le webhook orders/create n'arrive jamais ?
Les webhooks ne sont pas garantis. Réconciliez à intervalles réguliers : pour les brouillons dont la facture est partie depuis un moment sans commande enregistrée, demandez à Shopify si le brouillon a une commande, et si c'est le cas, faites-la passer par le même handler que celui du webhook.
Sources
- draftOrderCreate (2026-07), idempotent requests et inventoryAdjustQuantities (2026-07), lus le 30 septembre 2026
- La livraison des webhooks Shopify - délais, relances, doublons et identifiant de webhook - telle que résumée dans ce qui casse entre le moment du devis et celui de la conversion, lue du 20 au 22 septembre 2026
- Les services de conversion, de webhook et de réconciliation de QuotWay, lus le 30 septembre 2026 ; le code ci-dessus en est simplifié
Articles liés
- IngénieriePourquoi nous ne calculons jamais un total à partir des lignes affichées6 minutes de lecture
- IngénierieNous avons demandé à Shopify un nouveau type d'intention Sidekick. Ils l'ont livré.9 minutes de lecture
- IngénierieConstruire une intégration Shopify Flow : déclencheurs, actions et les pièges qui nous ont coûté un cycle11 minutes de lecture
Découvrez comment QuotWay gère cela sur votre boutique.