When a customer places an order, several systems each send you a message about it. Stripe says the payment went through. Shopify says the order is paid. Your own system says it's confirmed. These messages arrive in any order, sometimes twice, and now and then they don't agree.
This template gathers every message about the same order, compares what each system said, and gives you one record. If the systems disagree, the record says so and nothing is guessed. If one of them never reports, you find out about that too.
The problem, in plain terms
Picture one order for $49.99:
| System | What it tells you |
|---|---|
| Stripe | "I charged $49.99." |
| Shopify | "Order #1001 is paid, $50.00." |
| Your order system | "Order 1001 is paid." |
Most teams handle each of these with separate code. Whichever message lands last usually wins. So if Shopify's message shows up late, it quietly overwrites the correct amount and nobody notices until a customer writes in.
This template works differently. It treats the three messages as three witnesses to one event and listens to all of them before writing anything down.
How it works
Behind the scenes there are quite a few steps, but they all fit into four stages.
1. Every message comes in through one door
Stripe, Shopify and your internal system all send their webhooks to this one workflow. Each system describes an order differently, so the first thing the workflow does is translate every message into the same simple shape: order number, amount, currency, status and customer email.
It also picks out the order number. That's how it knows which messages belong together.
2. Messages are grouped by order, not by when they arrived
Each message is filed under its order number. Two situations are handled here:
- The same message sent twice (webhooks get resent all the time) is noticed and ignored.
- An older message arriving late can't replace a newer one from the same system. The workflow goes by when the event happened, not when the message showed up.
The first message for an order waits for the others. When the last system reports in, the waiting run is woken up and moves on. If a system never reports, the wait ends after a set time (15 minutes by default) so the order is never stuck.
3. The messages are compared
Now the workflow puts the messages side by side, one field at a time:
- Everything matches? You get one clean record.
- Two systems disagree (say $49.99 and $50.00)? That field is marked as a conflict and left empty, with every system's value shown next to it. The workflow never picks a winner on its own.
- A system never reported? The record is marked incomplete and names the missing system.
4. The final record is saved once
The finished record is written a single time. It shows every field, which system said what and when, and the original messages exactly as they arrived. If you need to figure out what happened later, the whole story is in one place.
If a message for this order turns up after the record is saved, it's added to the history but the record doesn't change. If that late message disagrees with the record, someone gets a heads-up.
What you get at the end
Every order ends up with one of three results:
| Result | What it means | What happens |
|---|---|---|
| Reconciled | Every system reported and they all agree. | The record is saved and sent on. |
| Conflict | At least one field doesn't match between systems. | Saved with the conflict clearly marked, and an alert goes out. |
| Incomplete | A system didn't report before the wait ran out. | Saved with the missing system listed, and an alert goes out. |
Setting it up
1. Connect your webhooks
Point your Stripe, Shopify and internal-system webhooks at this workflow's trigger URL in Unmeshed.
This template doesn't check webhook signatures. Do that before messages reach the workflow (Stripe and Shopify both sign their webhooks).
2. Make sure every system shares the order number
This is the one thing the template relies on. All three systems need to mention the same order number:
- Shopify: uses its order ID automatically.
- Stripe: add the Shopify order ID to the payment's metadata as
order_id. - Your internal system: send it as
order_id(orreconciliation_key).
3. Adjust the settings (optional)
All settings live in the first step, recon_config:
| Setting | Default | What it controls |
|---|---|---|
expectedSources | stripe, shopify, internal_oms | Which systems to wait for. Add a name here to bring in a fourth system. |
timeoutSeconds | 900 (15 min) | How long to wait for missing systems before closing the order. |
criticalFields | amount, currency, status, email | Which fields must agree across systems. |
amountToleranceMinor | 0 | How far amounts can differ, in cents, and still count as matching. |
Your internal system should describe payment status with the same words the template uses: paid, pending, refunded, partially_refunded, failed, cancelled.
4. Tell it where to send results (optional)
The template can send results to two places. Set them as variables in your Unmeshed namespace:
recon_sink_url: where finished records go, usually your own app or database, so the rest of your business knows the order is confirmed.recon_alert_webhook_url: where a person gets warned about conflicts, missing systems or late disagreements. A Slack incoming webhook works as is.
You can skip both. Records are still saved inside Unmeshed either way, and the workflow keeps running if these steps fail.
Try it in five minutes
You don't need real Stripe or Shopify accounts to see it work. Set timeoutSeconds to 120 while testing, then start the workflow three times, once with each input below. Start the second and third while the first is still waiting.
Stripe
{
"source": "stripe",
"payload": {
"id": "evt_test_8101",
"object": "event",
"type": "payment_intent.succeeded",
"created": 1727170000,
"data": {
"object": {
"id": "pi_test_8101",
"object": "payment_intent",
"amount": 4999,
"amount_received": 4999,
"currency": "usd",
"receipt_email": "[email protected]",
"metadata": { "order_id": "8101" }
}
}
}
}Shopify
{
"source": "shopify",
"headers": {
"X-Shopify-Topic": "orders/paid",
"X-Shopify-Webhook-Id": "shopify-wh-8101"
},
"payload": {
"id": 8101,
"name": "#8101",
"total_price": "49.99",
"currency": "USD",
"financial_status": "paid",
"email": "[email protected]",
"created_at": "2024-09-24T09:26:30Z",
"updated_at": "2024-09-24T09:26:45Z"
}
}Internal system
{
"source": "internal_oms",
"payload": {
"event_id": "oms-evt-8101",
"event_type": "order.payment_confirmed",
"order_id": "8101",
"amount": 49.99,
"currency": "USD",
"status": "paid",
"customer_email": "[email protected]",
"occurred_at": "2024-09-24T09:26:50Z"
}
}What you'll see: the first run waits. The second run stores its message and finishes. The third run sees that everyone has reported and wakes up the first, which then saves a Reconciled record for order 8101.
Want to see the other results? Use a new order number each time (8102, 8103…), because the workflow remembers orders it has already finished.
- A conflict: change Shopify's
total_priceto"50.00". - A missing system: leave out the internal system's message and wait for the timeout.
- A duplicate: send the Stripe message twice.
Questions people usually ask
What if a webhook never arrives? The order is closed once the wait runs out, using whatever did arrive. It's marked incomplete and lists the system that went quiet, so nothing hangs around forever.
What if two systems disagree? The disagreement is flagged and every system's value is shown next to it. Nothing is silently chosen for you.
Can I add a fourth system later?
Yes. Add its name to expectedSources and send its webhooks to the same place. If it can mention the same order number, nothing else needs to change.
How is this different from writing my own matching code? Most hand-written matching code quietly assumes messages arrive in order. This template doesn't assume that, so a late or repeated message can't overwrite a correct record.
Why do some steps show as "failed" in the run history? Two are expected:
- The step that opens an order (
claim_collector) fails on every message except the first for each order. That's on purpose: it's how exactly one run takes charge of an order. - The delivery and alert steps fail if you haven't set their URLs. That's harmless, and the record is still saved.
Good to know
- It's a template, not a finished product. It covers orders with Stripe, Shopify and one internal system out of the box. Other systems need their fields mapped in the first step,
normalize_webhook. - If the run holding an order open crashes, that order stays open. A small scheduled cleanup workflow would close any orders stuck past their deadline.
- A message that arrives in the exact moment the wait times out can miss the final record. It's still saved in that system's history.