Skip to content

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.

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.

Every Shopify webhook is signed with HMAC-SHA256 using your app’s shared secret. The signature is in the X-Shopify-Hmac-Sha256 header.

packages/api/src/integrations/shopify.ts
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.

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"]; // lowercased
const 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.

TopicWhat the worker does
APP_UNINSTALLEDDelete commerce + channel rows, revoke Admin token
customers/data_requestVerifies no buyer rows exist; logs structured confirmation; returns 200
customers/redactVerifies no buyer rows exist; logs structured confirmation; returns 200
shop/redactDefensively re-runs cleanup (oauth + commerce + channel rows); logs result

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:

  1. Parse the webhook payload (buyer id + email, or shop info)
  2. Query the tables that COULD hold buyer-keyed data
  3. Log a structured confirmation: { gdpr, shop, websiteId, buyer, verification }
  4. 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.

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.

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.