Examples
Whole integrations, written out. Copy one into a new script, give it the trigger it names, put its keys in Secrets, and change whatever you like — from then on it is your code.
On this page
Who posted it
Stripe payment → row and a Slack message #
Takes payment_intent.succeeded, writes the payment to a table with db.sql, and posts a line to Slack.
| Runs on | an address another application posts to |
| Table |
payments
(stripe_id text, amount real, currency text, email email, status text, refunded real)
|
def main(event):
# Stripe posts many kinds of event to one address; this handles one and ignores the
# rest. Adding a second is another `if`, not another address.
if event["event"] != "payment_intent.succeeded":
return
intent = event["payload"]["data"]["object"]
amount = intent["amount"] / 100.0
currency = intent["currency"].upper()
# Stripe delivers an event again when it is not sure the first one landed. Looking
# the payment up by its Stripe id first is what makes the second delivery update the
# row instead of adding another one for the same money.
if db["shop"].get("payments", stripe_id=intent["id"]):
db["shop"].sql(
"UPDATE payments SET status = ? WHERE stripe_id = ?",
["succeeded", intent["id"]],
)
else:
db["shop"].sql(
"INSERT INTO payments (stripe_id, amount, currency, email, status) "
"VALUES (?, ?, ?, ?, ?)",
[intent["id"], amount, currency, intent.get("receipt_email") or "", "succeeded"],
)
# The payment is written by now; the message is a courtesy. If Slack is down the run
# should still count as handled — a failed run is retried, and the retry would find
# nothing left to do but try Slack again.
try:
http(
secrets["SLACK_WEBHOOK_URL"],
json={"text": "Payment of %.2f %s" % (amount, currency)},
timeout=10,
)
except Exception as exc:
print("could not post to Slack:", exc)
return {"payment": intent["id"], "amount": amount, "currency": currency}
Stripe refund → update the payment #
Takes charge.refunded and updates the row the payment template wrote, rather than adding a second one for the same money.
| Runs on | an address another application posts to |
| Table |
payments
(stripe_id text, amount real, currency text, email email, status text, refunded real)
|
def main(event):
if event["event"] != "charge.refunded":
return
charge = event["payload"]["data"]["object"]
refunded = charge["amount_refunded"] / 100.0
status = "refunded" if charge.get("refunded") else "partly refunded"
# The same row the payment example wrote, found by the same column. A refund names
# the charge, so the payment intent is where the two meet.
stripe_id = charge.get("payment_intent") or charge["id"]
if db["shop"].get("payments", stripe_id=stripe_id):
db["shop"].sql(
"UPDATE payments SET refunded = ?, status = ? WHERE stripe_id = ?",
[refunded, status, stripe_id],
)
else:
# A refund for a payment this table never saw — one from before the script
# existed. Keep it anyway, so the money is accounted for.
db["shop"].sql(
"INSERT INTO payments (stripe_id, amount, currency, refunded, status) "
"VALUES (?, ?, ?, ?, ?)",
[stripe_id, charge["amount"] / 100.0, charge["currency"].upper(), refunded, status],
)
Stripe payouts → a table, every morning #
The other direction: nothing posts here. A clock runs it at 06:00 UTC and it calls Stripe with the official SDK to keep a payouts table current.
| Runs on | a schedule, 0 6 * * * (UTC) |
| Packages | stripe |
| Table |
payouts
(stripe_id text, amount real, currency text, status text, arrives_on integer)
|
import stripe
def main(event):
# Nothing comes in on a schedule: this is the code's turn to go and look.
stripe.api_key = secrets["STRIPE_KEY"]
# The latest twenty, not the whole history. A run may make 50 statements and each
# payout here is a lookup and a write; a morning never brings twenty new ones.
for payout in stripe.Payout.list(limit=20).data:
values = [
payout.amount / 100.0,
payout.currency.upper(),
payout.status,
payout.arrival_date,
]
if db["shop"].get("payouts", stripe_id=payout.id):
db["shop"].sql(
"UPDATE payouts SET amount = ?, currency = ?, status = ?, arrives_on = ? "
"WHERE stripe_id = ?",
values + [payout.id],
)
else:
db["shop"].sql(
"INSERT INTO payouts (amount, currency, status, arrives_on, stripe_id) "
"VALUES (?, ?, ?, ?, ?)",
values + [payout.id],
)
Shopify order → rows #
Takes orders/create and orders/updated, writes the order and each of its line items.
| Runs on | an address another application posts to |
| Table |
shopify_orders
(shop_order_id text, number integer, email email, total text, currency text, status text, placed_at text)
|
| Table |
shopify_items
(line_id text, shop_order_id text, title text, quantity integer, price text)
|
def main(event):
# Shopify names its event in the X-Shopify-Topic header, not in the body. This
# application reads that header, so `orders/create` arrives as it was sent.
if event["event"] not in ("orders/create", "orders/updated"):
return
order = event["payload"]
customer = order.get("customer") or {}
order_id = str(order["id"])
# Money arrives as a string — "42.50", not 42.5 — because a float cannot hold a
# price exactly. Keep it as text and convert only where you need arithmetic.
values = [
order.get("order_number"),
order.get("email") or customer.get("email") or "",
order.get("total_price") or "0",
order.get("currency") or "",
order.get("financial_status") or "",
order.get("created_at") or "",
]
# orders/updated is the same order again, so it is found by its Shopify id and
# updated rather than added a second time.
if db["shop"].get("shopify_orders", shop_order_id=order_id):
db["shop"].sql(
"UPDATE shopify_orders SET number = ?, email = ?, total = ?, currency = ?, "
"status = ?, placed_at = ? WHERE shop_order_id = ?",
values + [order_id],
)
else:
db["shop"].sql(
"INSERT INTO shopify_orders "
"(number, email, total, currency, status, placed_at, shop_order_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
values + [order_id],
)
# The lines are replaced as a set: an edited order can lose a line as well as gain
# one, and one INSERT for all of them keeps a fifty-line order to two statements.
db["shop"].sql("DELETE FROM shopify_items WHERE shop_order_id = ?", [order_id])
items = order.get("line_items") or []
if items:
params = []
for item in items:
params += [
str(item["id"]),
order_id,
item.get("title") or "",
item.get("quantity") or 0,
item.get("price") or "0",
]
# Only the placeholders are joined into the statement; the values stay in params.
db["shop"].sql(
"INSERT INTO shopify_items (line_id, shop_order_id, title, quantity, price) "
"VALUES " + ", ".join(["(?, ?, ?, ?, ?)"] * len(items)),
params,
)
return {"order": order_id, "lines": len(items)}
Stripe payout → QuickBooks deposit #
Every morning, each Stripe transfer becomes one deposit in QuickBooks, with the fee as its own negative line so it reconciles against the bank.
| Runs on | a schedule, 0 7 * * * (UTC) |
| Packages | stripe |
| Table |
qbo_deposits
(payout_id text, deposit_id text, gross real, fees real, net real, currency text, arrives_on integer, note text)
|
import json
import urllib.error
import urllib.parse
import urllib.request
import stripe
# ── Three account ids, filled in once ─────────────────────────────────────────
# QuickBooks books money into accounts by id, never by name, and there is no way to
# guess which of yours is which. Leave these blank and the first run prints the list
# with the ids beside it.
#
# BANK — where Stripe's transfer actually arrives.
# FEES — where Stripe's cut goes: "Bank Charges", or an expense account of your
# own called "Stripe fees".
# INCOME — what the sales themselves are booked to.
BANK_ACCOUNT_ID = ""
FEES_ACCOUNT_ID = ""
INCOME_ACCOUNT_ID = ""
def main(event):
if not (BANK_ACCOUNT_ID and FEES_ACCOUNT_ID and INCOME_ACCOUNT_ID):
for account in qbo_query("select Id, Name, AccountType from Account", "Account"):
print(account["Id"], account["AccountType"], account["Name"])
raise RuntimeError("fill in the three account ids at the top of this script")
stripe.api_key = secrets["STRIPE_KEY"]
# The latest twenty paid payouts. Each is a lookup here and maybe a write, and a run
# may make 50 statements; a morning never brings twenty new ones.
for payout in stripe.Payout.list(status="paid", limit=20).data:
# A Deposit takes no idempotency key, so nothing on QuickBooks' side stops a
# second run posting the same money again. This table is what stops it.
if db["shop"].get("qbo_deposits", payout_id=payout.id):
continue
deposit(payout)
# What counts as a sale. Everything else in a payout — Stripe's own monthly billing, a
# dispute, a topup — came out of the same transfer but is not income, and booking it to
# the income account would quietly overstate sales and understate costs by the same
# amount, which is the kind of error that balances and is still wrong.
SALES = ("charge", "payment", "refund", "payment_refund", "adjustment")
def deposit(payout):
# Every balance transaction in the payout except the payout itself. Their amounts
# less their fees come to exactly payout.amount, and that identity is what makes
# the deposit reconcile against the line on the bank statement.
sales = 0
fees = 0
for item in stripe.BalanceTransaction.list(payout=payout.id, limit=100).auto_paging_iter():
if item.type == "payout":
continue
if item.type in SALES:
# A refund arrives here as a negative amount, so this one sum covers both
# directions and there is no separate refund case to get wrong.
sales += item.amount
else:
fees -= item.amount
fees += item.fee
if sales - fees <= 0:
# Stripe debits the bank when a day's refunds beat its sales. That is a
# withdrawal, not a deposit, and QuickBooks will not accept it as one.
db["shop"].sql(
"INSERT INTO qbo_deposits (payout_id, net, currency, arrives_on, note) "
"VALUES (?, ?, ?, ?, ?)",
[
payout.id,
(sales - fees) / 100.0,
payout.currency.upper(),
payout.arrival_date,
"negative payout — enter this one by hand",
],
)
return
created = qbo("deposit", {
"DepositToAccountRef": {"value": BANK_ACCOUNT_ID},
# Harmless when it matches the company's own currency, and required when it
# does not — in which case multi-currency has to be on in QuickBooks.
"CurrencyRef": {"value": payout.currency.upper()},
"PrivateNote": "Stripe payout %s" % payout.id,
"Line": [
{
"Amount": round(sales / 100.0, 2),
"DetailType": "DepositLineDetail",
"Description": "Stripe sales",
"DepositLineDetail": {"AccountRef": {"value": INCOME_ACCOUNT_ID}},
},
{
# Negative on purpose. The fee came out before the money arrived, and
# a negative line is how a deposit says so without a second
# transaction that would then have to be matched to this one.
"Amount": round(-fees / 100.0, 2),
"DetailType": "DepositLineDetail",
"Description": "Stripe fees",
"DepositLineDetail": {"AccountRef": {"value": FEES_ACCOUNT_ID}},
},
],
})["Deposit"]
# Written straight after QuickBooks said yes, so the next run skips this payout.
db["shop"].sql(
"INSERT INTO qbo_deposits "
"(payout_id, deposit_id, gross, fees, net, currency, arrives_on, note) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[
payout.id,
created["Id"],
round(sales / 100.0, 2),
round(fees / 100.0, 2),
round((sales - fees) / 100.0, 2),
payout.currency.upper(),
payout.arrival_date,
"",
],
)
def qbo(path, body=None, query=None):
params = {"minorversion": "75"}
if query:
params["query"] = query
request = urllib.request.Request(
"https://%s/v3/company/%s/%s?%s"
% (
secrets["QUICKBOOKS_API"],
secrets["QUICKBOOKS_REALM"],
path,
urllib.parse.urlencode(params),
),
data=json.dumps(body).encode() if body is not None else None,
headers={
# A fresh access token every run. The refresh that produced it happened
# on the way in, and this script never learns that OAuth is involved.
"authorization": "Bearer " + secrets["QUICKBOOKS_TOKEN"],
"content-type": "application/json",
"accept": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
except urllib.error.HTTPError as exc:
# QuickBooks explains itself in the body, and urllib throws the body away
# unless you read it off the exception. Without this line every mistake is
# "HTTP Error 400: Bad Request" with no reason attached.
raise RuntimeError("QuickBooks refused: %s" % exc.read().decode("utf-8", "replace"))
def qbo_query(statement, kind):
answer = qbo("query", query=statement)
return answer.get("QueryResponse", {}).get(kind, [])
Shopify order → QuickBooks invoice #
Takes orders/create, finds or makes the customer, maps each line by SKU, and posts an invoice under the Shopify order number.
| Runs on | an address another application posts to |
| Table |
qbo_invoices
(shop_order_id text, invoice_id text, doc_number text, invoice_total real, shopify_total text, shopify_tax text, currency text)
|
import json
import urllib.error
import urllib.parse
import urllib.request
# ── Two item ids, filled in once ──────────────────────────────────────────────
# An invoice line has to point at a QuickBooks item, and Shopify's products are not
# QuickBooks items. Each line is looked up by SKU first; DEFAULT_ITEM_ID is the
# fallback when there is no match, and SHIPPING_ITEM_ID is for the shipping line.
# Leave them blank and the first run prints your items with their ids.
DEFAULT_ITEM_ID = ""
SHIPPING_ITEM_ID = ""
def main(event):
# Shopify names its event in the X-Shopify-Topic header; this application reads
# that header, so the topic arrives as it was sent.
if event["event"] not in ("orders/create", "orders/paid"):
return
if not DEFAULT_ITEM_ID:
for item in qbo_query("select Id, Name, Sku from Item", "Item"):
print(item["Id"], item.get("Sku") or "-", item["Name"])
raise RuntimeError("fill in the item ids at the top of this script")
order = event["payload"]
# Shopify retries a delivery it is not sure landed. An invoice posted twice is a
# conversation with an accountant, so this check comes before anything else.
if db["shop"].get("qbo_invoices", shop_order_id=str(order["id"])):
return
invoice(order)
def invoice(order):
lines = [line_for(item) for item in order.get("line_items") or []]
shipping = sum(float(line.get("price") or 0) for line in order.get("shipping_lines") or [])
if shipping > 0:
lines.append({
"Amount": round(shipping, 2),
"DetailType": "SalesItemLineDetail",
"Description": "Shipping",
"SalesItemLineDetail": {"ItemRef": {"value": SHIPPING_ITEM_ID}},
})
# Tax is deliberately not mapped. Shopify has already worked out what it charged
# and QuickBooks will work out its own from the customer and the company's tax
# settings; which of the two is right is a question about your jurisdiction, not
# a line of code. Both totals go in the row below so any gap is visible.
created = qbo("invoice", {
"CustomerRef": {"value": customer_for(order)},
"CurrencyRef": {"value": order.get("currency") or "USD"},
# Shopify's own number, so the two systems can be lined up by eye.
"DocNumber": str(order.get("order_number") or order["id"])[:21],
"PrivateNote": "Shopify order %s" % order["id"],
"Line": lines,
})["Invoice"]
# Written straight after QuickBooks said yes, so a redelivery stops at the check
# in main.
db["shop"].sql(
"INSERT INTO qbo_invoices (shop_order_id, invoice_id, doc_number, invoice_total, "
"shopify_total, shopify_tax, currency) VALUES (?, ?, ?, ?, ?, ?, ?)",
[
str(order["id"]),
created["Id"],
created.get("DocNumber") or "",
created.get("TotalAmt") or 0,
order.get("total_price") or "0",
order.get("total_tax") or "0",
order.get("currency") or "",
],
)
def customer_for(order):
person = order.get("customer") or {}
email = (order.get("email") or person.get("email") or "").strip()
if email:
found = qbo_query(
"select Id from Customer where PrimaryEmailAddr = '%s'" % escape(email),
"Customer",
)
if found:
return found[0]["Id"]
first = person.get("first_name") or ""
last = person.get("last_name") or ""
name = ("%s %s" % (first, last)).strip() or email or "Shopify customer"
body = {"DisplayName": name[:100]}
if email:
body["PrimaryEmailAddr"] = {"Address": email}
if first:
body["GivenName"] = first
if last:
body["FamilyName"] = last
try:
return qbo("customer", body)["Customer"]["Id"]
except RuntimeError as exc:
# DisplayName is unique in QuickBooks, so a second "John Smith" is refused
# outright. The one already there is the one this wanted.
if "Duplicate Name Exists" not in str(exc):
raise
found = qbo_query(
"select Id from Customer where DisplayName = '%s'" % escape(name[:100]),
"Customer",
)
if not found:
raise
return found[0]["Id"]
def line_for(item):
sku = (item.get("sku") or "").strip()
item_id = DEFAULT_ITEM_ID
if sku:
found = qbo_query("select Id from Item where Sku = '%s'" % escape(sku), "Item")
if found:
item_id = found[0]["Id"]
quantity = item.get("quantity") or 1
price = float(item.get("price") or 0)
return {
"Amount": round(price * quantity, 2),
"DetailType": "SalesItemLineDetail",
"Description": item.get("title") or "",
"SalesItemLineDetail": {
"ItemRef": {"value": item_id},
"Qty": quantity,
"UnitPrice": price,
},
}
def escape(value):
# QuickBooks' query language looks like SQL and is not: a quote inside a string is
# escaped with a backslash, and doubling it — the SQL habit — silently matches
# nothing instead of raising.
return value.replace("\\", "\\\\").replace("'", "\\'")
def qbo(path, body=None, query=None):
params = {"minorversion": "75"}
if query:
params["query"] = query
request = urllib.request.Request(
"https://%s/v3/company/%s/%s?%s"
% (
secrets["QUICKBOOKS_API"],
secrets["QUICKBOOKS_REALM"],
path,
urllib.parse.urlencode(params),
),
data=json.dumps(body).encode() if body is not None else None,
headers={
"authorization": "Bearer " + secrets["QUICKBOOKS_TOKEN"],
"content-type": "application/json",
"accept": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
except urllib.error.HTTPError as exc:
# The body is where QuickBooks says what was wrong, and urllib hides it on
# the exception rather than in the message.
raise RuntimeError("QuickBooks refused: %s" % exc.read().decode("utf-8", "replace"))
def qbo_query(statement, kind):
answer = qbo("query", query=statement)
return answer.get("QueryResponse", {}).get(kind, [])