Skip to content
hygge

Webhooks

Every webhook Hygge subscribes to and sends — Shopify, Whop, and the GDPR mandatory three.

Last updated 2018-10-20

Hygge subscribes to four Shopify webhooks (two functional, two GDPR), one Whop webhook (which carries multiple event types), and exposes one inbound endpoint per topic.

Shopify webhooks (subscribed during install)#

TopicEndpointPurpose
orders/create/api/shopify/webhooks/ordersReserved for future order-side syncing
app/uninstalled/api/shopify/webhooks/uninstallNulls our credentials (access_token, webhook_secret, script_tag_id)
customers/data_request/api/shopify/webhooks/gdpr/customers-data-requestGDPR data export request
customers/redact/api/shopify/webhooks/gdpr/customers-redactGDPR customer deletion
shop/redact/api/shopify/webhooks/gdpr/shop-redactFull shop deletion 48h after uninstall

All five subscriptions are auto-registered via the Shopify Admin API at the end of the OAuth callback. 422 (already subscribed) responses are tolerated so re-installs don't fail.

Signature verification#

Every Shopify webhook payload is HMAC-SHA256 signed against our app's shared secret (SHOPIFY_API_SECRET). We use a single shared verifier:

import { verifyShopifyHmac } from '@/lib/shopify-webhook-verify'

if (!verifyShopifyHmac(rawBody, hmacHeader, process.env.SHOPIFY_API_SECRET!)) {
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

The verifier is timing-safe (crypto.timingSafeEqual) and null-safe.

Whop webhook (single endpoint, multiple events)#

POST /api/webhooks/whop accepts:

  • payment.succeeded — buyer payment completed (original event name; the same handler now receives Hygge SaaS subscription events too)
  • membership_activated / membership.went_valid — SaaS subscription started or upgraded
  • membership_deactivated / membership.went_invalid — SaaS subscription cancelled
  • payment_succeeded (underscore variant) — recurring SaaS payment
  • payment_failed — SaaS payment retry exhausted

The endpoint disambiguates by checking whether data.product_id matches one of our Hygge tier products. If yes, it routes to the SaaS branch; otherwise the existing buyer-payment branch.

Signature verification (Whop)#

We try three secrets in order until one validates:

  1. WHOP_WEBHOOK_SECRET_HYGGE — global Hygge SaaS secret
  2. Per-merchant whop_webhook_secret from the merchants table (looked up by company_id)
  3. Optional fallback WHOP_WEBHOOK_SECRET env var

If none match, we return 200 { skipped: true } so Whop doesn't keep retrying — the alternative would be retry storms when a merchant rotates their secret.

GDPR specifics#

The three GDPR handlers all share the same skeleton:

  1. Verify HMAC → 401 if invalid
  2. Record the receipt in gdpr_audit_log (UNIQUE on shopify_webhook_id for idempotency)
  3. If duplicate → return 200 immediately
  4. Run topic-specific logic
  5. Mark processed → return 200

shop/redact is special — it has to delete data atomically across transactions, checkouts, shopify_stores, and (sometimes) merchants. We do that via a Postgres function redact_shop() to guarantee a single transaction.

The merchant row is preserved when subscription_status is in ('active', 'trialing', 'past_due') so a paying SaaS customer doesn't lose their account just because they uninstalled from a single Shopify store.

For the full compliance reference, see docs/gdpr.md in the repo.

Idempotency#

SourceMechanism
Shopify webhooksX-Shopify-Webhook-Idgdpr_audit_log.shopify_webhook_id UNIQUE constraint
Whop SaaS membership eventswhop_subscription_id + status + period_end triple — exact-match update is skipped
Whop buyer paymentNone today (potential future improvement: UNIQUE on transactions.whop_payment_id)

Retry behavior#

We don't retry from our side — Shopify and Whop both retry failed deliveries automatically. We treat unrecoverable handler errors (5xx from a downstream API, schema mismatches, etc.) by:

  1. Logging the error with full context
  2. Marking the audit row status='failed' (for GDPR webhooks)
  3. Still returning 200 to the source so they don't keep hammering us with a payload we can't process

This is intentional: retries help with transient network errors, not with code bugs. We'd rather see the failure in Sentry and ship a fix than ride out a retry storm.

Testing webhooks#

For Shopify GDPR webhooks specifically, you can simulate a payload from your terminal — see the GDPR doc's "Where to test" section for a copy-paste curl example with HMAC signing.

Spotted a typo or wrong fact? hello@payhygge.com — we ship doc fixes the same day.