Webhooks
import { Aside } from ‘@astrojs/starlight/components’;
Melbora receives webhooks from third-party services (today: Shopify) and processes them through a worker Lambda. Melbora itself does not emit outbound webhooks today — that’s on the roadmap.
How inbound webhooks flow
Section titled “How inbound webhooks flow”Shopify ──HMAC-signed POST──▶ /webhooks/shopify (public route) │ │ 1. Verify HMAC │ 2. Cross-check tenant │ 3. Enqueue to SQS │ 4. Return 200 immediately ▼ ┌──────────────┐ │ Webhook SQS │ └──────┬───────┘ │ ▼ ┌─────────────────────┐ │ Webhook worker │ │ (process per topic) │ └─────────────────────┘Returning 200 fast matters — Shopify cuts off your app after ~5 seconds of unresponsiveness and will retry. SQS lets us ack within milliseconds and process at our own pace.
HMAC verification
Section titled “HMAC verification”Every Shopify webhook is signed with HMAC-SHA256 using your app’s
shared secret. The signature is in the X-Shopify-Hmac-Sha256 header.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyShopifyHmac( body: string, signatureHeader: string, sharedSecret: string,): boolean { const expected = createHmac("sha256", sharedSecret) .update(body, "utf8") .digest("base64"); const a = Buffer.from(expected); const b = Buffer.from(signatureHeader); return a.length === b.length && timingSafeEqual(a, b);}Always use a timing-safe compare. A regular === comparison leaks
the secret length over time via measurable timing differences.
Cross-tenant guard
Section titled “Cross-tenant guard”The webhook subscription URL we register with Shopify carries
websiteId as a query-string param:
POST https://<api-host>/webhooks/shopify?websiteId=wb_01J7...Shopify also includes the shop domain in X-Shopify-Shop-Domain on
every delivery. The handler cross-checks both:
const websiteId = event.queryStringParameters?.websiteId;const claimedShop = event.headers["x-shopify-shop-domain"]; // lowercasedconst services = await websiteServicesRepo.list(websiteId);const commerce = services.find( (s) => s.serviceType === "commerce" && s.externalId === claimedShop,);
if (!commerce) { // HMAC was valid but the (websiteId, shop) pair doesn't match // what we have stored. Return 200 with a structured reason so // Shopify doesn't trigger its 19-attempt retry storm against a // request we'll never accept. return ok({ received: false, reason: "tenant_mismatch" });}Deliveries that arrive without websiteId (legacy subscriptions
predating this guard) get the same 200 + reason treatment:
{ received: false, reason: "no_websiteId" }. Returning 200 on
known-bad webhooks is intentional — Shopify’s retry policy fires
~19 times over ~48 hours for any non-2xx, and we’d rather absorb
the request than waste platform budget on requests we can’t serve.
Topics handled
Section titled “Topics handled”| Topic | What the worker does |
|---|---|
APP_UNINSTALLED | Delete commerce + channel rows, revoke Admin token |
customers/data_request | Verifies no buyer rows exist; logs structured confirmation; returns 200 |
customers/redact | Verifies no buyer rows exist; logs structured confirmation; returns 200 |
shop/redact | Defensively re-runs cleanup (oauth + commerce + channel rows); logs result |
GDPR handler details
Section titled “GDPR handler details”Melbora stores no buyer PII today — no orders, customers, or carts
are mirrored into DDB. The only email we hold is the shop owner’s
contactEmail on the OAuth token row (not a buyer of the merchant).
The handlers therefore:
- Parse the webhook payload (buyer id + email, or shop info)
- Query the tables that COULD hold buyer-keyed data
- Log a structured confirmation:
{ gdpr, shop, websiteId, buyer, verification } - Return 200 to Shopify
shop/redact additionally re-runs the same cleanup as
APP_UNINSTALLED (delete OAuth token + commerce + channel_* rows)
as a defensive backstop in case the uninstall webhook was dropped.
Webhook subscription registration
Section titled “Webhook subscription registration”When a merchant completes the OAuth flow, Melbora registers webhook
subscriptions via Shopify’s Admin GraphQL. The topic enum is inlined
into the document string (passing it as a WebhookSubscriptionTopic!
variable is rejected by Shopify’s API — see the inline comment in
packages/api/src/integrations/shopify.ts):
mutation C($sub: WebhookSubscriptionInput!) { webhookSubscriptionCreate(topic: ORDERS_CREATE, webhookSubscription: $sub) { webhookSubscription { id topic } userErrors { field message } }}Safe because every topic value flows from a server-side allow-list
(SHOPIFY_WEBHOOK_TOPICS in the same file) — the registration code
asserts the topic is in that allow-list before inlining as
defense-in-depth.
Outbound webhooks (planned)
Section titled “Outbound webhooks (planned)”Melbora will emit its own webhooks for:
- Provisioning completed / failed
- Website status change (active / failed / suspended)
- Domain verification
- Channel install / uninstall
- Content document edits
Not built yet. Track the changelog for the announcement.
See also
Section titled “See also”- Concepts → Commerce + channels — the data model webhooks update
- How it fits together — where the webhook worker sits