This is the workflow a growing e-commerce operation eventually needs and rarely builds correctly. A single Shopify order triggers payment capture, stock allocation, Xero invoicing with fee separation, warehouse pick-pack, and two customer emails, with automatic retry on payment failure, a dedicated backorder path, and Slack escalation carrying full context when something genuinely needs a human.
Workflow diagram
flowchart TD
A[Shopify Order Webhook] --> B[Log Raw Order]
B --> C[Extract Order Fields]
C --> D[Capture Stripe Payment]
D --> E{Payment Success?}
E -->|No| F[Wait 5 min]
F --> G[Retry 1]
G --> H{Retry 1 OK?}
H -->|No| I[Wait 30 min]
I --> J[Retry 2]
J --> K{Retry 2 OK?}
K -->|No| L[Slack Escalation]
L --> M[Stop]
E -->|Yes| N[Merge Payment Paths]
H -->|Yes| N
K -->|Yes| N
N --> O[Check Stock]
O --> P{In Stock?}
P -->|No| Q[Backorder ETA Email]
Q --> R[Flag Backorder]
P -->|Yes| S[Reserve Stock]
S --> T[Merge Stock Path]
R --> T
T --> U[Create Xero Invoice]
U --> V[Calculate Stripe Fee]
V --> W[Post Fee Expense]
W --> X[Request Pick Pack]
X --> Y[Wait For Tracking]
Y --> Z[Get Tracking Number]
Z --> AA[Order Confirmation Email]
AA --> AB[Shipping Confirmation Email]
AB --> AC[Log To Ops Dashboard] Every branch shown here (IF/Switch outcomes) exists as a real conditional in the downloadable JSON, not a simplification for this diagram.
Why native app integrations are not enough
Every tool in this stack ships its own integrations. Shopify talks to Stripe. Klaviyo talks to Shopify. Your 3PL has a Shopify app. Each of these works correctly in isolation, which is exactly why the combination fails in ways that are hard to see.
The problem is that native integrations have no shared view of the order. When a payment fails, Shopify marks the order unpaid, but nothing tells the warehouse not to ship, nothing tells Xero not to invoice, and nothing decides whether to retry. Each system does its one job correctly and the coordination between them, which is where the actual business logic lives, falls to whoever notices first.
At low volume this is manageable because a human sees every order. The failure mode arrives with growth: exceptions accumulate faster than anyone reviews them, and the business develops a quiet, permanent backlog of half-processed orders that surfaces only through customer complaints.
Two-stage payment retry and why the timing matters
Payment failures are not uniform. A genuinely declined card and a transient network error look similar at the API level but need completely different responses. Retrying immediately fails for both. Retrying too slowly loses the sale.
This workflow uses a two-stage backoff: five minutes, then thirty. The first retry catches transient issues, temporary processor outages, brief network failures, rate limiting. The thirty-minute gap catches the common real-world case of a customer who notices the decline, moves money between accounts, and would succeed on a second attempt.
Only after both retries fail does a human get paged, and the Slack escalation includes the order ID, the customer, the failure reason and both retry timestamps. This is the difference between an alert someone can act on and an alert that starts an investigation. The Escalation Runbook sticky note in the workflow documents exactly what a human should do next, so the knowledge is not trapped in one person's head.
The backorder branch, telling customers early
The In Stock? node creates the second major branch. The instinct when stock is short is to hold the order and sort it out later. In practice that means the customer finds out about the delay when the delivery does not arrive, which converts a minor inventory problem into a support ticket and a refund request.
The backorder path instead sends a Klaviyo email immediately with a real ETA, flags the order, and continues through invoicing and the rest of the pipeline. The customer knows within minutes, the order stays tracked in the same system as every other order, and the business keeps the sale far more often than it would with silence.
Note that both stock branches rejoin at Merge Stock Path before invoicing. A backordered item still gets invoiced and still gets a fee line, only the fulfillment timing differs. Splitting the branches earlier and duplicating the invoicing logic in each is the more obvious design and it doubles your maintenance surface for no benefit.
Waiting for the warehouse without polling
The Wait For Tracking Number node uses n8n's webhook-resume mode rather than polling on a timer. The execution pauses, persists its state, and resumes only when the warehouse's fulfillment webhook fires with a tracking number.
This matters at scale. A polling approach with a hundred orders in flight means a hundred repeated API calls against your 3PL every interval, most returning nothing. Webhook resume holds the execution at effectively zero cost until there is genuinely something to do.
It also makes the customer communication correct. The shipping confirmation email fires when a tracking number genuinely exists, not on a guessed delay after the pick-pack request. Customers receiving a "your order has shipped" email with no working tracking link is a small detail that erodes trust disproportionately.
Observability: the part most workflows skip
Three nodes in this workflow exist purely for observability: Log Raw Order at the start, Log Order To Ops Dashboard at the end, and the Slack escalation in between. None of them affect whether an order processes correctly, and all three are the reason you can answer questions about the system later.
The final log entry records timing data for each step the order passed through. After a few weeks that table answers questions no dashboard in Shopify, Stripe or your 3PL can: what percentage of orders hit the retry path, how long the warehouse actually takes between pick-pack request and tracking number, whether backorders cluster around particular SKUs.
For a workflow this size, that visibility is not a nice-to-have. A 32-node pipeline with branches is genuinely difficult to reason about from execution logs alone, and the operational data is what tells you which branch needs attention before it becomes a problem.
Node-by-node reference
| Node | Type | Role |
|---|---|---|
| Shopify Order Webhook | Webhook | Fires on order creation |
| Log Raw Order | HTTP Request | Durable record before processing, enables replay |
| Capture Stripe Payment | HTTP Request | Initial payment capture attempt |
| Payment Success? | IF | Entry to the retry ladder |
| Wait 5 Min / Wait 30 Min | Wait ×2 | Two-stage backoff between retry attempts |
| Retry Stripe Payment 1 / 2 | HTTP Request ×2 | The two automatic retry attempts |
| Escalate Payment Failure To Slack | HTTP Request | Human escalation with full context, after both retries |
| Merge Payment Success Paths | Merge | Rejoins first-attempt and both retry successes |
| Check Stock Availability | HTTP Request | Queries the warehouse/3PL inventory API |
| In Stock? | IF | Splits the fulfillment and backorder paths |
| Send Backorder ETA Email | HTTP Request | Immediate customer notification with a real ETA |
| Reserve Stock | HTTP Request | Allocates inventory for the in-stock path |
| Create Xero Invoice | HTTP Request | Accounting entry for the order |
| Calculate Stripe Fee Line / Post Fee | Set + HTTP | Fee separation so the bank feed reconciles |
| Request Pick Pack | HTTP Request | Fulfillment instruction to the warehouse |
| Wait For Tracking Number | Wait (webhook resume) | Zero-cost pause until the warehouse responds |
| Order / Shipping Confirmation Email | HTTP Request ×2 | Customer comms at the correct moments |
| Log Order To Ops Dashboard | HTTP Request | Final record with per-step timing data |
32 total nodes in the downloadable file, including sticky-note documentation embedded directly on the canvas.
Key logic, in code
Stripe fee calculation for the Xero expense line
// Prefer the actual fee Stripe reports over a calculated estimate.
// balance_transaction is only present once the charge has settled.
const charge = $json.charge ?? {};
const gross = Number($json.grossAmount);
const reportedFee = charge.balance_transaction?.fee != null
? charge.balance_transaction.fee / 100
: null;
const stripeFee = reportedFee ?? Number((gross * 0.029 + 0.30).toFixed(2));
return [{
json: {
...$json,
stripeFee,
netAmount: Number((gross - stripeFee).toFixed(2)),
feeSource: reportedFee != null ? 'stripe_reported' : 'estimated',
}
}]; Slack escalation payload with full failure context
{
"channel": "#ops-escalations",
"blocks": [
{
"type": "header",
"text": { "type": "plain_text", "text": "Payment failed after 2 retries" }
},
{
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": "*Order:*\n{{ $json.orderId }}" },
{ "type": "mrkdwn", "text": "*Customer:*\n{{ $json.customerEmail }}" },
{ "type": "mrkdwn", "text": "*Amount:*\n{{ $json.grossAmount }} {{ $json.currency }}" },
{ "type": "mrkdwn", "text": "*Decline reason:*\n{{ $json.last_payment_error.message }}" },
{ "type": "mrkdwn", "text": "*Retry 1:*\n{{ $json.retry1At }}" },
{ "type": "mrkdwn", "text": "*Retry 2:*\n{{ $json.retry2At }}" }
]
},
{
"type": "context",
"elements": [
{ "type": "mrkdwn", "text": "Next step: retry or refund in Stripe, then replay this execution from Log Raw Order." }
]
}
]
} Before / after
| Metric | Before | After this workflow |
|---|---|---|
| Systems coordinated per order | 5–7, each independently | 5–7, from one trigger with shared state |
| Payment failures needing a human | Every one | Only those surviving 2 automatic retries |
| Customer notified of a backorder | When delivery fails to arrive | Within minutes, with a real ETA |
| Silent exception backlog | Grows with volume | Zero, every exception alerts with context |
Prerequisites
- n8n v1.40+ with webhook-resume Wait nodes available
- Shopify Admin API access and order-creation webhook permission
- Stripe secret key with payment_intents capture permission
- A warehouse or 3PL exposing inventory, fulfillment and a fulfillment-complete webhook
- Xero OAuth2 credentials, Klaviyo private API key, Slack bot token, Airtable PAT
Common pitfalls
Match retry timing to your payment provider's guidance
Five and thirty minutes suit card payments. SEPA direct debit, BACS and other delayed-settlement methods need days, not minutes, and retrying too aggressively can incur per-attempt fees.
Webhook-resume executions consume a slot while waiting
On self-hosted n8n with a constrained worker pool, a hundred orders awaiting tracking numbers can exhaust concurrency. Size your workers for peak in-flight orders, not peak orders per hour.
Do not invoice before payment succeeds
The invoicing nodes sit after Merge Payment Success Paths deliberately. Inverting that order produces Xero invoices for orders that never paid, which is significantly harder to unwind than it is to avoid.
Idempotency on the warehouse call
If an execution is replayed after a partial failure, Request Pick Pack can fire twice. Pass the Shopify order ID as an idempotency key so your 3PL rejects the duplicate rather than double-shipping.
Want this deployed, configured and monitored?
The template is free. Wiring in your real credentials, tuning the logic to your business, and keeping it running when an upstream API changes is what we do.
Get my free automation plan →Frequently asked questions
Does this work with WooCommerce instead of Shopify?
Yes, replace the trigger and the order-field extraction. The payment, inventory, accounting and comms logic downstream is platform-agnostic.
What if our 3PL has no fulfillment webhook?
Replace Wait For Tracking Number with a scheduled polling loop, accepting the extra API calls. Webhook resume is preferable where available but not mandatory.
Is 32 nodes overkill for a small store?
Below roughly 50 orders a day, yes, a simpler pattern is usually enough. This earns its complexity once exception handling starts consuming real staff time every week.
Can the retry count be changed?
Yes. The ladder is explicit rather than a loop precisely so you can add, remove or re-time stages without rewriting logic. Each stage is a Wait plus an HTTP Request plus an IF.