---
title: "Wie aus einem angenommenen Angebot genau ein Shopify-Bestellentwurf wird"
description: "draftOrderCreate hat bei API 2026-07 keinen Idempotenzschlüssel. So wird ein Angebot genau einmal umgewandelt: Claim, Eingabe, Outbox, Webhooks."
url: "https://www.quotway.com/de/blog/idempotent-draft-order-creation"
type: "blog post"
category: "Engineering"
published: "2026-09-30"
verified: "2026-09-30"
shopify_api_version: "2026-07"
audience: "Entwickler und Agenturen, die eine Integration vom Angebot zum Bestellentwurf auf Shopify bauen oder prüfen"
scope: "draftOrderCreate idempotent machen, wenn Shopify bei API 2026-07 keinen Idempotenzschlüssel bietet: ein Datensatz pro Umwandlung, gespeicherte Eingabe, ein Datenbank-Claim mit Ablauffenster, eine Erfolgstransaktion mit transaktionaler Outbox, idempotente Verarbeitung von orders/create, Abgleich und warum ein Anlegen mit unbekanntem Ergebnis nie automatisch wiederholt wird"
locale: "de"
source: "QuotWay - B2B Quote & Negotiation App for Shopify"
---

# Wie aus einem angenommenen Angebot genau ein Shopify-Bestellentwurf wird

In der Shopify Admin API Version 2026-07 nimmt `draftOrderCreate` keinen Idempotenzschlüssel entgegen. Manche Mutations tun das – `inventoryAdjustQuantities` hat in 2026-01 einen optionalen `@idempotent`-Schlüssel bekommen und ihn in 2026-04 zur Pflicht gemacht, und seine Referenzseite sagt das auch –, aber die Mutation für Bestellentwürfe dokumentiert nichts dergleichen. Rufen Sie sie zweimal mit derselben Eingabe auf, haben Sie zwei Bestellentwürfe, zwei Rechnungen und einen Käufer, der die eine bezahlt und die andere reklamiert.

Eine App, die angenommene Angebote in Bestellentwürfe umwandelt, muss die Umwandlung also selbst idempotent machen. Dieser Beitrag zeigt, wie wir das in QuotWay gebaut haben: ein Datensatz pro Umwandlung, die exakt kalkulierte Eingabe gespeichert und erneut gesendet, ein Claim (Reservierung) in der Datenbank, damit immer nur ein Worker Shopify aufruft, das Ergebnis in einer Transaktion zusammen mit seinem ausgehenden Event festgehalten, ein idempotenter Webhook-Handler für die Bestellung, die zurückkommt, und ein Sweep, der auffängt, was die Webhooks verpassen – der aber bewusst den einen Fall nicht wiederholt, in den er nicht hineinsehen kann.

Die Fehlerfälle rund um die Umwandlung – abweichende Steuer, abweichende Preise, Bestand – behandelt [was zwischen Angebot und Umwandlung kaputtgeht](/blog/quote-to-draft-order-failure-modes). Hier geht es nur um einen davon: sicherzustellen, dass sie genau einmal passiert.

Die Shopify-Fakten wurden am 30. September 2026 bei API-Version 2026-07 gegen Shopifys Referenzseiten geprüft. Der Code ist aus QuotWays Umwandlungsservices vereinfacht: Namen gekürzt, interne Bezeichner entfernt.

## Woher Duplikate kommen
Alles, was `draftOrderCreate` zweimal aufrufen kann, wird es irgendwann tun:

- **Die Job-Queue wiederholt.** Ein Anlegen, das aus Sicht des Aufrufers in ein Timeout läuft, kann auf Shopifys Seite gelungen sein. Die Queue sieht einen Fehler und versucht es erneut.
- **Zwei Worker übernehmen denselben Job.** At-least-once-Queues stellen einen Job unter Last oder während eines Deployments doppelt zu.
- **Jemand klickt zweimal.** „In Bestellentwurf umwandeln“ bei langsamer Verbindung.
- **Automatisierung und ein Mensch handeln gleichzeitig.** Eine Regel zur automatischen Umwandlung feuert, während jemand aus dem Team von Hand umwandelt.
- **Der Prozess stirbt zwischen Shopifys Antwort und Ihrem Datenbank-Schreibvorgang.** Der Entwurf existiert; Ihr Datensatz sagt, er existiere nicht.

Die ersten vier sind Races zwischen Versuchen. Der fünfte ist etwas anderes: ein Versuch, dessen Ergebnis Sie nicht kennen. Das Design muss beides abdecken, und beides braucht unterschiedliche Antworten.

## Ein Datensatz pro Umwandlung
Jede Umwandlung ist eine Zeile, angelegt bevor Shopify aufgerufen wird, eindeutig pro *Umwandlungsgruppe* – der Menge angenommener Positionen, die zu einem Bestellentwurf werden. Ein vollständig angenommenes Angebot hat eine Gruppe; ein teilweise angenommenes und in zwei Schritten umgewandeltes Angebot hat zwei, und jede wird ihr eigener Bestellentwurf. Die Zeile enthält:

| Feld | Warum es da ist |
| --- | --- |
| Ein Schlüssel aus Angebot, Gruppe und Version | Eindeutig; ein zweiter Versuch findet den ersten |
| Der exakt kalkulierte `DraftOrderInput` | Wird bei jedem Versuch unverändert gesendet |
| Ein Claim-Zeitstempel | Wer gerade Shopify aufruft, und seit wann |
| Die ID des Shopify-Bestellentwurfs | Sobald gesetzt, ist die Umwandlung erledigt |
| Die ID der Shopify-Bestellung | Wird gesetzt, wenn der Käufer bezahlt |
| Fehlerzähler und letzter Fehler | Was ein Mensch sieht, wenn es hängt |

> **Der Lebenslauf eines Umwandlungsdatensatzes**
>
> Ein Zustandsdiagramm. Eine Umwandlung beginnt als Entwurf, wird zu Kalkuliert, sobald die Eingabe für den Bestellentwurf berechnet und gespeichert ist, und dann reserviert ein Worker sie per Claim. Von Reserviert führt ein erfolgreiches Anlegen zu Umgewandelt, mit gespeicherter ID des Shopify-Bestellentwurfs; ein fehlgeschlagenes Anlegen führt zu Fehlgeschlagen, gibt den Claim frei, und die Queue wiederholt von dort aus. Wenn der Käufer bezahlt, verknüpft der Webhook orders/create die Bestellung, und die Umwandlung ist Bestellung verknüpft. Ein gestrichelter Pfad zeigt den unbekannten Fall: Veraltet ein Claim ohne ID des Bestellentwurfs, kann der Prozess gestorben sein, nachdem Shopify den Entwurf angelegt hat, deshalb markiert der Sweep ihn für einen Menschen, statt ihn zu wiederholen.
>
> Jeder Pfad ist idempotent außer dem gestrichelten – dem Fall, in den eine Maschine nicht hineinsehen kann.

## Schritt 1: einmal kalkulieren, dieselbe Eingabe senden
Bevor irgendetwas angelegt wird, wird die Umwandlung *kalkuliert*: Positionen, Preise, Rabatt, Versand, Zahlungsziele und der Käufer (als `purchasingEntity` bei einem unternehmensbezogenen Angebot) werden zu einem `DraftOrderInput` zusammengesetzt, mit `draftOrderCalculate` gegen Shopify geprüft, und der Händler sieht sich jede Abweichung an. Diese Eingabe wird in der Umwandlungszeile gespeichert – das exakte Objekt, nicht die Zutaten, um es neu zu bauen.

Jeder Versuch des Anlegens sendet dieses gespeicherte Objekt. Eine Wiederholung, die die Eingabe *neu berechnet*, könnte einen Entwurf erzeugen, den der Händler nie freigegeben hat – ein Katalogpreis, der sich über Nacht bewegt hat, ein Steuerbetrag, der sich verschoben hat –, und das ist ein Korrektheitsfehler, auch wenn es kein Duplikat ist.

## Schritt 2: abkürzen, dann Claim setzen
Das Anlegen läuft als Job in der Queue. Seine ersten beiden Schritte entscheiden, ob es Shopify überhaupt aufruft:

```ts
// 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);
}
```

Zwei Dinge machen das sicher. Die Entscheidung trifft die Datenbank, in einer einzigen bedingten Anweisung, sodass nicht zwei Worker beide „nicht reserviert“ lesen und beide weitermachen können – ein Update trifft, das andere trifft nichts. Und der Claim ist ein Zeitstempel, kein Lock: Stirbt der Worker, der ihn hält, veraltet der Claim nach zwei Minuten, statt die Umwandlung für immer zu blockieren.

Der Verlierer scheitert nicht. Hat der Gewinner die ID des Bestellentwurfs bereits gespeichert, gibt der Verlierer sie als Erfolg zurück; wenn nicht, wirft er einen eigenen „läuft noch“-Fehler, und die Queue versucht es kurz darauf erneut – bis dahin findet sie meist das fertige Ergebnis.

## Schritt 3: Shopify aufrufen, das Ergebnis in einer Transaktion festhalten
Der Worker, der den Claim hält, sendet die gespeicherte Eingabe an `draftOrderCreate`. Liefert Shopify User Errors oder schlägt der Aufruf fehl, hält der Worker den Fehler fest, erhöht den Fehlerzähler, **gibt den Claim frei**, damit die Wiederholung nicht zwei Minuten wartet, und markiert die Umwandlung als fehlgeschlagen. Die Queue wiederholt einige wenige Male mit Backoff; danach sieht ein Mensch den Fehler.

Liefert Shopify einen Bestellentwurf, passiert alles Weitere in einer einzigen Datenbanktransaktion:

```ts
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
```

Die Outbox ist so wichtig wie die ID. Das Event `quote.converted`, das Webhooks und Shopify Flow erreicht, wird in derselben Transaktion wie die Zustandsänderung festgehalten und nach dem Commit versendet. Ein Event, das *vor* dem Commit rausgeht, könnte eine Umwandlung ankündigen, die zurückgerollt wurde; ein Event, das *nach* dem Commit aus dem Speicher gesendet wird, geht verloren, wenn der Prozess dazwischen stirbt. In der Transaktion geschrieben, existiert es genau dann, wenn die Umwandlung existiert – das ist die transaktionale Outbox.

## Schritt 4: Die Bestellung kommt über einen Webhook zurück
Der Bestellentwurf ist nicht das Ende. Wenn der Käufer bezahlt, legt Shopify eine Bestellung an und sendet `orders/create` – mindestens einmal, manchmal mehrfach, gelegentlich gar nicht, und ohne Garantie zum Zeitpunkt. Die Bestellung trägt ein Notiz-Attribut, das die Umwandlungsgruppe nennt; darüber findet der Handler zurück.

Drei Ebenen halten das idempotent:

1. **Jede Webhook-Zustellung wird festgehalten, eindeutig pro Shop und Shopifys Webhook-ID.** Eine erneute Zustellung ist ein doppelter Insert, und der Unique Constraint macht aus einem gleichzeitigen Paar einen Gewinner und ein „schon gesehen“ – kein Race zwischen Prüfen und Einfügen.
2. **Der Handler selbst ist idempotent.** Hat die Umwandlung bereits eine Bestell-ID, ist ein zweiter Empfang ein No-op.
3. **Der Webhook kann schneller sein als unser eigener Schreibvorgang.** Ein Käufer, der Sekunden nach Erhalt der Rechnung bezahlt, kann `orders/create` auslösen, bevor die Umwandlungszeile committet ist. Der Handler wertet eine fehlende Zeile nicht als „nicht unsere“ – das Notiz-Attribut sagt, dass sie es ist –, also plant er eine verzögerte Wiederholung und versucht es erneut.

```ts
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);
```

## Schritt 5: der Sweep
Ein geplanter Job läuft alle 15 Minuten und schließt die Lücken, die Events nicht schließen können:

| Bedingung | Was der Sweep tut |
| --- | --- |
| Rechnung vor mehr als 7 Tagen gesendet, Entwurfs-ID gespeichert, keine Bestell-ID | Fragt Shopify nach dem Bestellentwurf. Hat er eine Bestellung, hat der Käufer bezahlt und der Webhook wurde verpasst: Die Bestellung wird **durch denselben Handler** nachgespielt, den der Webhook nutzt, sodass der Nachholpfad der getestete Pfad ist |
| Vor mehr als 24 Stunden kalkuliert, nie angelegt | Meldet es; der Händler entscheidet – es kann einfach noch warten |
| Kalkuliert, keine Entwurfs-ID, seit mehr als 5 Minuten unverändert | **Markiert es für einen Menschen. Wiederholt nicht.** |

Jeder Zweig ist pro Lauf gedeckelt, und die Abfragen bei Shopify laufen nur wenige gleichzeitig, sodass ein Rückstau auf den nächsten Lauf verschoben wird, statt das Zeitbudget des Jobs zu sprengen.

## Warum der Sweep das Unbekannte nicht wiederholt
Die letzte Zeile ist die, die Eigenentwicklungen überspringen. Eine Umwandlung, die kalkuliert und reserviert wurde und dann ohne Entwurfs-ID verstummt ist, hat zwei mögliche Geschichten: Der Aufruf hat Shopify nie erreicht, oder Shopify hat den Entwurf angelegt und der Prozess ist gestorben, bevor er ihn festhalten konnte. Aus Sicht der Datenbank sehen beide gleich aus. Ohne Idempotenzschlüssel für `draftOrderCreate` erzeugt eine Wiederholung der zweiten Geschichte einen zweiten Bestellentwurf – und eine zweite Rechnung, die der Käufer bezahlen kann.

Deshalb macht der Sweep den Fall sichtbar, statt ihn zu wiederholen. Die Lösung ist eine Suche, keine Wiederholung: die Bestellentwürfe des Shops nach der Referenz der Umwandlung durchsuchen (QuotWays umgewandelte Entwürfe tragen `quotway` und einen Tag mit der Angebotsreferenz sowie das Notiz-Attribut der Umwandlungsgruppe), den Entwurf übernehmen, wenn er existiert, und erst dann einen anlegen, wenn nicht. Dieser Schritt braucht heute einen Menschen, weil er selten ist und ein Fehler Geld kostet; fügt Shopify `@idempotent` zu `draftOrderCreate` hinzu, kommt der Schlüssel an den Aufruf und dieser ganze Zweig wird zu einer normalen Wiederholung.

## Was passiert, wenn der Prozess bei jedem Schritt stirbt
| Der Prozess stirbt … | Hinterlassener Zustand | Was als Nächstes passiert |
| --- | --- | --- |
| Vor dem Claim | Kalkuliert, nicht reserviert | Die Queue wiederholt; der nächste Versuch setzt den Claim normal |
| Nach dem Claim, vor dem Aufruf von Shopify | Reserviert, keine Entwurfs-ID | Der Claim veraltet nach 2 Minuten; die Wiederholung der Queue reserviert neu und ruft Shopify einmal auf |
| Während des Aufrufs, Shopify hat ihn nie erhalten | Reserviert, keine Entwurfs-ID | Wie oben – aber nicht unterscheidbar von der nächsten Zeile |
| Nachdem Shopify den Entwurf angelegt hat, vor der Transaktion | Reserviert, keine Entwurfs-ID, **Entwurf existiert in Shopify** | Der Sweep markiert es nach 5 Minuten; ein Mensch sucht den Entwurf über seine Referenz und übernimmt ihn |
| Nach der Transaktion, vor dem Versand aus der Outbox | Umgewandelt, Event ausstehend | Der nächste Lauf des Workers versendet es – ein Cron alle 15 Minuten ist das Sicherheitsnetz, ein verpasster Anstoß verzögert das Event also, verliert es aber nie |
| Während der Bestell-Webhook unterwegs ist | Umgewandelt, keine Bestell-ID | Shopify stellt erneut zu; oder der 7-Tage-Sweep fragt Shopify und spielt die Bestellung nach |

## Eine Checkliste für Ihren eigenen Aufbau
- Ein Umwandlungsdatensatz pro künftigem Bestellentwurf, eindeutig, angelegt vor jedem Aufruf von Shopify.
- Den exakt kalkulierten `DraftOrderInput` speichern; erneut senden, nie neu berechnen.
- Den Claim mit einem bedingten Update und einem Ablauffenster setzen – kein Lock im Speicher.
- Den Claim bei einem sauberen Fehler freigeben, damit Wiederholungen nicht warten.
- Entwurfs-ID, Zustandsänderungen und ausgehende Events in einer Transaktion festhalten; Events nach dem Commit versenden.
- Webhooks über Shopifys Webhook-ID mit einem Unique Constraint deduplizieren und zusätzlich den Handler idempotent machen.
- „Webhook vor meinem eigenen Schreibvorgang“ als Wiederholung behandeln, nicht als „nicht meiner“.
- Abgleichen, indem Sie Shopify fragen, und durch denselben Handler nachspielen.
- Ein Anlegen, dessen Ergebnis Sie nicht sehen können, nie automatisch wiederholen; zuerst den Entwurf suchen.
- Die Referenzseite von `draftOrderCreate` bei jedem API-Release prüfen – taucht `@idempotent` auf, nutzen Sie es.

Dieselben Fragen, an einen Anbieter gerichtet statt an Ihren eigenen Code, stehen in [wie Sie eine Shopify-B2B-App bewerten](/blog/shopify-b2b-app-evaluation-checklist), und die Felder, die ein umgewandelter Bestellentwurf trägt – Tags, die Notiz, die Attribute, an denen ein ERP festmachen kann –, stehen in [Integrationsmuster für ERP, CRM und PIM](/blog/shopify-b2b-erp-crm-pim-integration-patterns).

## Wo das in QuotWay steckt
Alles oben ist QuotWays Umwandlungspfad, wie er heute läuft, in jedem Tarif: Ein angenommenes Angebot, oder jeder angenommene Teil davon, wird zu einem Shopify-[Bestellentwurf](/features/convert-to-orders), und das Event `quote.converted` feuert einmal pro Bestellentwurf – an Shopify Flow ab Professional und an [Webhooks und die API](/features/api-webhooks) im Tarif Enterprise. Die Tarife stehen auf der [Preisseite](/pricing).

## Häufige Fragen

### Unterstützt Shopifys draftOrderCreate Idempotenzschlüssel?

Nicht bei API-Version 2026-07: Seine Referenzseite dokumentiert weder eine `@idempotent`-Direktive noch ein Idempotenz-Argument. Mutations, die das unterstützen, sagen es auf ihrer Referenzseite – `inventoryAdjustQuantities` etwa hat seinen Schlüssel in 2026-01 optional und in 2026-04 zur Pflicht gemacht. Prüfen Sie die Seite für Bestellentwürfe bei jedem vierteljährlichen Release.

### Wie verhindere ich doppelte Bestellentwürfe, wenn ein Job wiederholt wird?

Führen Sie einen Datensatz pro Umwandlung, angelegt vor dem Aufruf, und lassen Sie genau einen Worker ihn per bedingtem Datenbank-Update reservieren. Eine Wiederholung, die eine gespeicherte Entwurfs-ID findet, gibt sie zurück; eine Wiederholung, die den Claim verliert, wartet und versucht es erneut. Senden Sie die exakt kalkulierte Eingabe erneut, damit eine Wiederholung auch keinen anderen Entwurf erzeugen kann.

### Was, wenn mein Prozess abstürzt, nachdem Shopify den Bestellentwurf angelegt hat?

Ihr Datensatz zeigt einen Claim, aber keine Entwurfs-ID, und Sie können nicht erkennen, ob Shopify den Aufruf erhalten hat. Wiederholen Sie nicht blind – das ist der eine Weg zu einem echten Duplikat. Durchsuchen Sie die Bestellentwürfe des Shops nach der Referenz, die Sie angehängt haben (ein Tag oder ein Notiz-Attribut), übernehmen Sie den Entwurf, wenn er da ist, und legen Sie nur dann einen an, wenn nicht.

### Wie gehe ich mit doppelten orders/create-Webhooks um?

Halten Sie jede Zustellung mit Shopifys Webhook-ID unter einem Unique Constraint fest, sodass eine erneute Zustellung als Duplikat abgewiesen wird, selbst wenn zwei Kopien gleichzeitig ankommen. Machen Sie dann den Handler selbst idempotent: Ist die Bestellung bereits verknüpft, tun Sie nichts.

### Was, wenn der orders/create-Webhook vor meinem eigenen Datenbank-Schreibvorgang ankommt?

Das kann passieren, wenn ein Käufer Sekunden nach der Rechnung bezahlt. Trägt die Bestellung Ihre Referenz, Ihr Datensatz existiert aber noch nicht, planen Sie eine verzögerte Wiederholung, statt sie als „nicht unsere“ zu verwerfen.

### Was, wenn der orders/create-Webhook nie ankommt?

Webhooks sind nicht garantiert. Gleichen Sie nach Zeitplan ab: Fragen Sie bei Entwürfen, deren Rechnung vor einer Weile rausging und zu denen keine Bestellung festgehalten ist, Shopify, ob der Entwurf eine Bestellung hat, und wenn ja, geben Sie sie durch denselben Handler, den der Webhook nutzt.

## Quellen

- [draftOrderCreate (2026-07)](https://shopify.dev/docs/api/admin-graphql/2026-07/mutations/draftOrderCreate), [Idempotente Requests](https://shopify.dev/docs/api/usage/idempotent-requests) und [inventoryAdjustQuantities (2026-07)](https://shopify.dev/docs/api/admin-graphql/2026-07/mutations/inventoryAdjustQuantities), gelesen am 30. September 2026
- Webhook-Zustellung bei Shopify – Timeouts, Wiederholungen, Duplikate und die Webhook-ID –, zusammengefasst in [was zwischen Angebot und Umwandlung kaputtgeht](/blog/quote-to-draft-order-failure-modes#quellen), gelesen am 20.–22. September 2026
- QuotWays Umwandlungs-, Webhook- und Abgleichservices, gelesen am 30. September 2026; der Code oben ist daraus vereinfacht
