Coverage for atlas_ecommerce/routers/shopify.py: 93%
45 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-25 16:36 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-25 16:36 +0000
1"""Shopify webhook intake.
3Shopify allows one second to connect and five for the whole request, and deletes
4the subscription after eight consecutive failures. So this route only verifies,
5records and acknowledges; account provisioning happens in a worker. A delivery we
6have already stored is acknowledged again without re-processing, because Shopify
7redelivers freely and at-least-once is the only guarantee on offer.
8"""
10import json
11from logging import getLogger
13from fastapi import APIRouter, Depends, Request, Response, status
14from pydantic import ValidationError
15from pymongo.errors import DuplicateKeyError
17from atlas_ecommerce.config import Settings, get_settings
18from atlas_ecommerce.models import OrderDb, ShopifyOrder
19from atlas_ecommerce.security.shopify_hmac import (
20 HMAC_HEADER,
21 SHOP_DOMAIN_HEADER,
22 TOPIC_HEADER,
23 WEBHOOK_ID_HEADER,
24 verify_shopify_hmac,
25)
27logger = getLogger(__name__)
29router = APIRouter(prefix="/webhooks/shopify", tags=["shopify"])
32@router.post("/orders", status_code=status.HTTP_200_OK)
33async def receive_order(
34 request: Request,
35 response: Response,
36 settings: Settings = Depends(get_settings),
37) -> dict[str, str]:
38 # Must be the unparsed bytes: re-serialising parsed JSON never matches.
39 raw_body = await request.body()
40 secret = request.app.state.secrets.shopify_webhook_secret.get_secret_value()
42 if not verify_shopify_hmac(raw_body, request.headers.get(HMAC_HEADER), secret):
43 logger.warning("Rejected Shopify delivery with an invalid signature")
44 response.status_code = status.HTTP_401_UNAUTHORIZED
45 return {"status": "invalid_signature"}
47 shop_domain = request.headers.get(SHOP_DOMAIN_HEADER)
48 accepted = settings.shopify_shop_domains
49 if accepted and (shop_domain or "").lower() not in accepted:
50 logger.warning("Rejected signed delivery from unexpected shop %s", shop_domain)
51 response.status_code = status.HTTP_401_UNAUTHORIZED
52 return {"status": "unexpected_shop"}
54 webhook_id = request.headers.get(WEBHOOK_ID_HEADER)
55 topic = request.headers.get(TOPIC_HEADER)
57 try:
58 payload = json.loads(raw_body)
59 order = ShopifyOrder.model_validate(payload)
60 except (ValidationError, json.JSONDecodeError):
61 # Signed by Shopify but not a shape we understand. Retrying cannot fix
62 # that, so acknowledge and keep the subscription alive.
63 logger.exception("Signed Shopify delivery %s failed to parse", webhook_id)
64 return {"status": "unparseable"}
66 existing = await OrderDb.find_one({"shopify_order_id": order.id})
67 if existing is not None:
68 logger.info("Ignoring duplicate delivery for order %s", order.id)
69 return {"status": "duplicate"}
71 customer = order.customer
72 record = OrderDb(
73 shopify_order_id=order.id,
74 webhook_id=webhook_id,
75 topic=topic,
76 shop_domain=shop_domain,
77 email=order.contact_email,
78 first_name=customer.first_name if customer else None,
79 last_name=customer.last_name if customer else None,
80 order_number=order.order_number,
81 financial_status=order.financial_status,
82 raw=payload,
83 )
85 try:
86 await record.insert()
87 except DuplicateKeyError:
88 # Concurrent redelivery beat us to the insert. Both are the same order.
89 logger.info("Concurrent duplicate delivery for order %s", order.id)
90 return {"status": "duplicate"}
92 logger.info("Accepted Shopify order %s for provisioning", order.id)
93 return {"status": "accepted"}