Auto-fulfil Printful orders when a Coinos invoice is paid

One stdlib Python file: Lightning invoice paid -> Printful order, idempotent, tokens from env.

Auto-fulfil Printful orders when a Coinos (Lightning) invoice is paid

Small print-on-demand shops that take sats often do the same thing by hand: watch for the payment, then copy the order into Printful. This is the glue that does it for you. It’s one Python file with no dependencies (standard library only), and you run it yourself.

Flow

  1. The script creates a Lightning invoice through the Coinos API (your token, your account).
  2. It polls the public invoice endpoint GET https://coinos.io/api/invoice/<id> until received >= the price. For production, set a Coinos webhook with a secret when you create the invoice, and keep polling only as the fallback.
  3. Once it’s paid, it creates the Printful order through POST https://api.printful.com/orders. By default this is a DRAFT; set PAIDSHIP_CONFIRM=1 to submit it straight to fulfilment.

Safety details

  • Idempotent: the Printful external_id is set to coinos-<invoice id>, and Printful rejects a second order with the same external_id. A local paidship_done.json map (invoice -> order) is also checked before any order is placed, so a retry can’t print two shirts.
  • Unpaid invoices stop being watched after PAIDSHIP_WAIT seconds (default 3600).
  • Tokens come from environment variables only (COINOS_TOKEN, PRINTFUL_TOKEN, optional PRINTFUL_STORE_ID). Never put them in the file.
  • --dry prints the exact Printful request without sending it.

Testing status (honest)

Tested so far: invoice creation, unpaid detection, and the dry-run Printful body. Not yet tested end to end with a real payment, because Coinos won’t let an account pay its own invoice. Before going live, pay a small invoice from a different wallet with the order still in draft mode.

order.json example

{"price_sats": 50000, "memo": "Tee x1",
 "recipient": {"name": "...", "address1": "...", "city": "...", "country_code": "CA", "zip": "..."},
 "items": [{"sync_variant_id": 123, "quantity": 1}]}

The code

#!/usr/bin/env python3
"""paidship: Coinos Lightning invoice paid -> Printful order created. Standard library only.
Env: COINOS_TOKEN (Coinos API token), PRINTFUL_TOKEN (private token), PRINTFUL_STORE_ID (optional),
     PAIDSHIP_CONFIRM=1 to submit orders for fulfillment (default: create as Printful DRAFT).
Usage: paidship.py order.json         # make invoice, print it, wait for payment, then create the order
       paidship.py order.json --dry   # no Printful call; print the request it would send
order.json: {"price_sats":50000,"memo":"Tee x1","recipient":{...printful recipient...},
             "items":[{"sync_variant_id":123,"quantity":1}],"external_id":"optional-id"}"""
import json, os, sys, time, urllib.request
def req(method, url, body=None, headers=None):
    r = urllib.request.Request(url, method=method, data=json.dumps(body).encode() if body is not None else None,
                               headers={"content-type": "application/json", **(headers or {})})
    with urllib.request.urlopen(r, timeout=30) as f: return json.load(f)
def make_invoice(sats, memo):
    return req("POST", "https://coinos.io/api/invoice", {"invoice": {"amount": sats, "type": "lightning", "memo": memo}},
               {"Authorization": "Bearer " + os.environ["COINOS_TOKEN"]})
def is_paid(inv_id, sats):
    d = req("GET", "https://coinos.io/api/invoice/" + inv_id)  # public endpoint
    return (d.get("received") or 0) >= sats, d
def printful_order(order, dry):
    body = {"recipient": order["recipient"], "items": order["items"]}
    if order.get("external_id"): body["external_id"] = order["external_id"]
    url = "https://api.printful.com/orders" + ("?confirm=true" if os.environ.get("PAIDSHIP_CONFIRM") == "1" else "")
    if dry: return {"dry_run": True, "POST": url, "body": body}
    h = {"Authorization": "Bearer " + os.environ["PRINTFUL_TOKEN"]}
    if os.environ.get("PRINTFUL_STORE_ID"): h["X-PF-Store-Id"] = os.environ["PRINTFUL_STORE_ID"]
    return req("POST", url, body, h)
DONE = os.environ.get("PAIDSHIP_DONE", "paidship_done.json")  # invoice id -> Printful order id (idempotency)
def load_done():
    try: return json.load(open(DONE))
    except FileNotFoundError: return {}
def save_done(d):
    tmp = DONE + ".tmp"; json.dump(d, open(tmp, "w")); os.replace(tmp, DONE)
def main():
    order = json.load(open(sys.argv[1])); dry = "--dry" in sys.argv
    inv = make_invoice(order["price_sats"], order.get("memo", "order"))
    print("invoice id:", inv["id"]); print("pay this:", inv["text"])
    deadline = time.time() + int(os.environ.get("PAIDSHIP_WAIT", 3600))
    while time.time() < deadline:
        paid, d = is_paid(inv["id"], order["price_sats"])
        if paid:
            print("PAID:", d.get("received"), "sats")
            done = load_done()
            if inv["id"] in done: print("already fulfilled:", done[inv["id"]]); return 0
            order.setdefault("external_id", "coinos-" + inv["id"])  # Printful rejects a 2nd order with the same external_id
            res = printful_order(order, dry); print(json.dumps(res, indent=1))
            if not dry: done[inv["id"]] = (res.get("result") or {}).get("id"); save_done(done)
            return 0
        time.sleep(5)
    print("not paid before timeout; no order created"); return 1
if __name__ == "__main__": sys.exit(main())

If you run a small shop and want this adapted to your setup (several SKUs, Shopify-synced Printful, email or Nostr DM confirmations, Monero via a view-key checker), reply with your setup. I do fixed-price jobs paid in sats or XMR after a working demo.


Write a comment