Reconciler Orders and payments
Shopify authorized payments that expire before capture
Your store captures payments manually, maybe so you can confirm stock or screen an order before you take the money. At checkout Shopify only authorizes the card, so the order sits at an Authorized status with the money held. Then it gets forgotten. About seven days later the hold expires, the money is released, and you have an order you were never paid for. Here is why it happens and a small job that captures the eligible orders before the window closes.
With manual capture, Shopify only authorizes the card, so the order sits at Authorized and the hold expires in about seven days. Run a small Python or Node.js job that lists orders with financial status authorized, keeps the ones with an outstanding totalCapturableSet and a successful authorization transaction, and calls the orderCapture mutation before the window closes. Full code, tests, and a dry run guard are below.
The problem in plain words
Shopify can split a payment into two steps. At checkout it authorizes the card, which reserves the money but does not move it. Later you capture, which actually takes it. This happens when your payment capture setting is manual instead of automatic.
The catch is that an authorization does not last forever. Shopify Payments holds it for about seven days, then lets it go. If nobody captures the order in that time, the reserved money is released back to the buyer. The order still shows Authorized, but the chance to collect is gone.
Why it happens
Manual capture is a useful setting, but it puts a human step in the middle of every order, and human steps get missed. A few common ways stores end up with expired authorizations:
- The store set payment capture to manual in Settings, Payments, and then forgot that authorized orders need a capture.
- The person who used to capture orders each day left, and no one took over.
- A busy period buried the authorized orders under new ones, so the oldest aged past the seven day window.
- An order was held for a fraud or stock review that ran longer than a week.
The setting is documented by Shopify, and the seven day window is a rule of the payment provider, not a bug. The problem is that nothing reminds you to capture, so the fix is to add that reminder as a job. See the citations at the end for the exact docs.
An order at Authorized with a positive totalCapturableSet is money you are allowed to take but have not. Rather than mark it paid, we capture it, which is the real financial action Shopify expects, tied to the authorization transaction that is already on the order. A job that does this before the window closes turns a silent loss into a collected sale.
The fix, as a flow
We do not touch the live checkout. We add a job that lists the authorized orders, keeps the ones that still have an amount left to capture and a successful authorization to capture against, and captures each one. Capturing moves the real money and marks the order paid, the same way the Admin Capture button would.
Build it step by step
Get an Admin API access token
Create a custom app in your Shopify admin under Settings, Apps and sales channels, Develop apps. Give it the read_orders and write_orders scopes and install it to get an Admin API access token that starts with shpat_. Keep the token and the shop domain in environment variables, never in the file.
pip install requests
export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export DRY_RUN="true" # start safe, change to false to capture
// Node 18+ has fetch built in, no dependencies needed
export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export DRY_RUN="true" // start safe, change to false to capture
List the authorized orders and their capturable amount
Ask for orders whose financial status is authorized, and read back the display financial status, the totalCapturableSet that says how much is left to capture, and the transactions so we can find the authorization to capture against. We page through with a cursor so the job handles a backlog.
ORDERS_QUERY = """
query($cursor: String) {
orders(first: 50, after: $cursor, query: "financial_status:authorized") {
pageInfo { hasNextPage endCursor }
nodes {
id name displayFinancialStatus
totalCapturableSet { shopMoney { amount currencyCode } }
transactions(first: 10) { id kind status }
}
}
}"""
def authorized_orders():
cursor = None
while True:
data = gql(ORDERS_QUERY, {"cursor": cursor})["orders"]
for node in data["nodes"]:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
const ORDERS_QUERY = `
query($cursor: String) {
orders(first: 50, after: $cursor, query: "financial_status:authorized") {
pageInfo { hasNextPage endCursor }
nodes {
id name displayFinancialStatus
totalCapturableSet { shopMoney { amount currencyCode } }
transactions(first: 10) { id kind status }
}
}
}`;
async function* authorizedOrders() {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { cursor })).orders;
for (const node of data.nodes) yield node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
Decide, with one pure function
Keep the eligibility rule in its own function. The order must be at Authorized, must have a positive amount left in totalCapturableSet, and must carry a successful authorization transaction to capture against. If any of those is missing, we do not touch the order. A pure function like this is easy to read and easy to test.
def authorization_txn(order):
for txn in order.get("transactions") or []:
if txn.get("kind") == "AUTHORIZATION" and txn.get("status") == "SUCCESS":
return txn
return None
def capturable_amount(order):
money = (order.get("totalCapturableSet") or {}).get("shopMoney") or {}
return money.get("amount")
def eligible_to_capture(order):
if order.get("displayFinancialStatus") != "AUTHORIZED":
return False
amount = capturable_amount(order)
if amount is None or float(amount) <= 0:
return False
return authorization_txn(order) is not None
export function authorizationTxn(order) {
for (const txn of order.transactions || []) {
if (txn.kind === "AUTHORIZATION" && txn.status === "SUCCESS") return txn;
}
return null;
}
export function capturableAmount(order) {
return order.totalCapturableSet?.shopMoney?.amount ?? null;
}
export function eligibleToCapture(order) {
if (order.displayFinancialStatus !== "AUTHORIZED") return false;
const amount = capturableAmount(order);
if (amount === null || parseFloat(amount) <= 0) return false;
return authorizationTxn(order) !== null;
}
Capture the payment with orderCapture
When an order is eligible, call the orderCapture mutation with the order id, the amount from totalCapturableSet, and the id of the authorization transaction as the parent. Use finalCapture so Shopify releases any small remainder. Always read back userErrors and stop on them rather than pretend the capture worked.
CAPTURE_MUTATION = """
mutation($id: ID!, $amount: MoneyInput!, $parent: ID!) {
orderCapture(input: { id: $id, amount: $amount, parentTransactionId: $parent, finalCapture: true }) {
transaction { id status kind }
userErrors { field message }
}
}"""
def capture(order):
money = order["totalCapturableSet"]["shopMoney"]
parent = authorization_txn(order)["id"]
result = gql(CAPTURE_MUTATION, {
"id": order["id"],
"amount": {"amount": money["amount"], "currencyCode": money["currencyCode"]},
"parent": parent,
})["orderCapture"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
return result["transaction"]["status"]
const CAPTURE_MUTATION = `
mutation($id: ID!, $amount: MoneyInput!, $parent: ID!) {
orderCapture(input: { id: $id, amount: $amount, parentTransactionId: $parent, finalCapture: true }) {
transaction { id status kind }
userErrors { field message }
}
}`;
async function capture(order) {
const money = order.totalCapturableSet.shopMoney;
const parent = authorizationTxn(order).id;
const result = (await gql(CAPTURE_MUTATION, {
id: order.id,
amount: { amount: money.amount, currencyCode: money.currencyCode },
parent,
})).orderCapture;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}
Wire it together with a dry run guard
The loop ties every piece together. On the first few runs, leave DRY_RUN on so the job only reports which orders it would capture. Read the output, agree with it, then switch it off. Capturing moves real money, so run it a few times a day to leave room before the hold expires.
Always start with DRY_RUN=true. Capturing takes money from the buyer, so only capture orders you truly intend to fulfill. If you use manual capture to screen orders, keep the human review, and let the job capture only the ones that are still Authorized after the review.
The full code
Here is the complete job in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only captures orders that are still Authorized with an amount left to capture.
View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.
"""Capture Shopify orders whose payment is authorized but never captured.
Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("capture_authorized")
SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ORDERS_QUERY = """
query($cursor: String) {
orders(first: 50, after: $cursor, query: "financial_status:authorized") {
pageInfo { hasNextPage endCursor }
nodes {
id name displayFinancialStatus
totalCapturableSet { shopMoney { amount currencyCode } }
transactions(first: 10) { id kind status }
}
}
}"""
CAPTURE_MUTATION = """
mutation($id: ID!, $amount: MoneyInput!, $parent: ID!) {
orderCapture(input: { id: $id, amount: $amount, parentTransactionId: $parent, finalCapture: true }) {
transaction { id status kind }
userErrors { field message }
}
}"""
def gql(query, variables=None):
r = requests.post(
ENDPOINT,
json={"query": query, "variables": variables or {}},
headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("errors"):
raise RuntimeError(body["errors"])
return body["data"]
def authorization_txn(order):
for txn in order.get("transactions") or []:
if txn.get("kind") == "AUTHORIZATION" and txn.get("status") == "SUCCESS":
return txn
return None
def capturable_amount(order):
money = (order.get("totalCapturableSet") or {}).get("shopMoney") or {}
return money.get("amount")
def eligible_to_capture(order):
if order.get("displayFinancialStatus") != "AUTHORIZED":
return False
amount = capturable_amount(order)
if amount is None or float(amount) <= 0:
return False
return authorization_txn(order) is not None
def capture(order):
money = order["totalCapturableSet"]["shopMoney"]
parent = authorization_txn(order)["id"]
result = gql(CAPTURE_MUTATION, {
"id": order["id"],
"amount": {"amount": money["amount"], "currencyCode": money["currencyCode"]},
"parent": parent,
})["orderCapture"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
return result["transaction"]["status"]
def authorized_orders():
cursor = None
while True:
data = gql(ORDERS_QUERY, {"cursor": cursor})["orders"]
for node in data["nodes"]:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def run():
captured = 0
for order in authorized_orders():
if not eligible_to_capture(order):
continue
log.info("Order %s eligible. %s", order["name"], "would capture" if DRY_RUN else "capturing")
if not DRY_RUN:
capture(order)
captured += 1
log.info("Done. %d order(s) %s.", captured, "to capture" if DRY_RUN else "captured")
if __name__ == "__main__":
run()
/**
* Capture Shopify orders whose payment is authorized but never captured.
* Run on a schedule. Safe to run again and again.
*/
import { pathToFileURL } from "node:url";
const SHOP = process.env.SHOPIFY_SHOP || "example.myshopify.com";
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN || "shpat_dummy";
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function authorizationTxn(order) {
for (const txn of order.transactions || []) {
if (txn.kind === "AUTHORIZATION" && txn.status === "SUCCESS") return txn;
}
return null;
}
export function capturableAmount(order) {
return order.totalCapturableSet?.shopMoney?.amount ?? null;
}
export function eligibleToCapture(order) {
if (order.displayFinancialStatus !== "AUTHORIZED") return false;
const amount = capturableAmount(order);
if (amount === null || parseFloat(amount) <= 0) return false;
return authorizationTxn(order) !== null;
}
async function gql(query, variables = {}) {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Shopify ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(JSON.stringify(body.errors));
return body.data;
}
const ORDERS_QUERY = `
query($cursor: String) {
orders(first: 50, after: $cursor, query: "financial_status:authorized") {
pageInfo { hasNextPage endCursor }
nodes {
id name displayFinancialStatus
totalCapturableSet { shopMoney { amount currencyCode } }
transactions(first: 10) { id kind status }
}
}
}`;
const CAPTURE_MUTATION = `
mutation($id: ID!, $amount: MoneyInput!, $parent: ID!) {
orderCapture(input: { id: $id, amount: $amount, parentTransactionId: $parent, finalCapture: true }) {
transaction { id status kind }
userErrors { field message }
}
}`;
async function* authorizedOrders() {
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { cursor })).orders;
for (const node of data.nodes) yield node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function capture(order) {
const money = order.totalCapturableSet.shopMoney;
const parent = authorizationTxn(order).id;
const result = (await gql(CAPTURE_MUTATION, {
id: order.id,
amount: { amount: money.amount, currencyCode: money.currencyCode },
parent,
})).orderCapture;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}
export async function run() {
let captured = 0;
for await (const order of authorizedOrders()) {
if (!eligibleToCapture(order)) continue;
console.log(`Order ${order.name} eligible. ${DRY_RUN ? "would capture" : "capturing"}`);
if (!DRY_RUN) await capture(order);
captured++;
}
console.log(`Done. ${captured} order(s) ${DRY_RUN ? "to capture" : "captured"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The eligibility rule is the part most worth testing, because it decides whether a real payment gets captured. Because we kept eligible_to_capture pure, the test needs no network and no Shopify account. It just feeds in plain objects and checks the answer.
from capture_authorized import eligible_to_capture, authorization_txn
def order(**over):
base = {
"displayFinancialStatus": "AUTHORIZED",
"totalCapturableSet": {"shopMoney": {"amount": "50.00", "currencyCode": "USD"}},
"transactions": [{"id": "gid://shopify/OrderTransaction/1", "kind": "AUTHORIZATION", "status": "SUCCESS"}],
}
base.update(over)
return base
def test_eligible_when_authorized_with_amount_and_auth_txn():
assert eligible_to_capture(order()) is True
def test_skip_when_not_authorized():
assert eligible_to_capture(order(displayFinancialStatus="PAID")) is False
def test_skip_when_nothing_capturable():
assert eligible_to_capture(order(totalCapturableSet={"shopMoney": {"amount": "0.00", "currencyCode": "USD"}})) is False
def test_skip_when_no_authorization_transaction():
assert eligible_to_capture(order(transactions=[{"id": "t", "kind": "SALE", "status": "SUCCESS"}])) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { eligibleToCapture, authorizationTxn } from "./capture-authorized.js";
const order = (over = {}) => ({
displayFinancialStatus: "AUTHORIZED",
totalCapturableSet: { shopMoney: { amount: "50.00", currencyCode: "USD" } },
transactions: [{ id: "gid://shopify/OrderTransaction/1", kind: "AUTHORIZATION", status: "SUCCESS" }],
...over,
});
test("eligible when authorized with amount and auth txn", () => {
assert.equal(eligibleToCapture(order()), true);
});
test("skip when not authorized", () => {
assert.equal(eligibleToCapture(order({ displayFinancialStatus: "PAID" })), false);
});
test("skip when nothing capturable", () => {
assert.equal(eligibleToCapture(order({ totalCapturableSet: { shopMoney: { amount: "0.00", currencyCode: "USD" } } })), false);
});
test("skip when no authorization transaction", () => {
assert.equal(eligibleToCapture(order({ transactions: [{ id: "t", kind: "SALE", status: "SUCCESS" }] })), false);
});
Case studies
The capture step that walked out the door
A homeware store authorized every order and captured it by hand once stock was confirmed. The one person who did that left, and for two weeks nobody captured anything. Dozens of authorizations quietly expired, and the shop shipped goods it was never paid for.
A job on a twice daily schedule now captures the authorized orders that passed the stock check before their window closes. The loss stopped the day it went live.
The review queue that aged out
A store held high value orders for a manual fraud review. Good orders sometimes sat for over a week while staff caught up, and by the time they were approved the authorization was already gone.
The team ran the job in dry run first, saw exactly which approved orders were still capturable, then let it capture them. Now an approved order is captured within hours, well inside the window.
After this runs on a schedule, an authorized order is no longer a race against a hidden clock. Approved orders are captured automatically before the hold expires, and the only ones left at Authorized are the ones a human has not cleared yet. No more silent losses from a forgotten capture.
FAQ
Why does my Shopify order stay on Authorized and never get paid?
When your payment capture setting is manual, Shopify only authorizes the card at checkout and waits for you to capture. If no one captures the order, the authorization expires after about seven days and the held money is released. A job that captures the eligible authorized orders before that window closes fixes it.
How long does a Shopify payment authorization last?
For Shopify Payments an authorization generally lasts about seven days before it expires and the hold is released. Some third party gateways use shorter windows. After it expires you cannot capture and must ask the customer to pay again.
What is orderCapture in the Shopify Admin API?
orderCapture is the Admin GraphQL mutation that captures a previously authorized payment on an order. You give it the order id, the amount to capture, and the parent authorization transaction id, and it moves the money and marks the order paid.
Related field notes
Citations
On the problem:
- Shopify Help Center: capturing payments and the manual capture setting. help.shopify.com/en/manual/payments/capturing-payments
- Shopify Help Center: payment authorization periods and when a hold expires. help.shopify.com/en/manual/payments/shopify-payments/payment-authorization
- Shopify Community: authorized orders expiring before capture. community.shopify.com shopify discussions
On the solution:
- Shopify Admin GraphQL: the
orderCapturemutation. shopify.dev/docs/api/admin-graphql/latest/mutations/orderCapture - Shopify Admin GraphQL: the Order object, including
totalCapturableSetandtransactions. shopify.dev/docs/api/admin-graphql/latest/objects/Order - Shopify Admin GraphQL: the
ordersquery and its search syntax. shopify.dev/docs/api/admin-graphql/latest/queries/orders
Stuck on a tricky one?
If you have a problem in Shopify orders, payments, subscriptions, inventory, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this save you a lost sale?
If this kept an authorization from expiring on you, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi