Webhooks
Every webhook Hygge subscribes to and sends — Shopify, Whop, and the GDPR mandatory three.
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)#
| Topic | Endpoint | Purpose |
|---|---|---|
orders/create | /api/shopify/webhooks/orders | Reserved for future order-side syncing |
app/uninstalled | /api/shopify/webhooks/uninstall | Nulls our credentials (access_token, webhook_secret, script_tag_id) |
customers/data_request | /api/shopify/webhooks/gdpr/customers-data-request | GDPR data export request |
customers/redact | /api/shopify/webhooks/gdpr/customers-redact | GDPR customer deletion |
shop/redact | /api/shopify/webhooks/gdpr/shop-redact | Full 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 upgradedmembership_deactivated/membership.went_invalid— SaaS subscription cancelledpayment_succeeded(underscore variant) — recurring SaaS paymentpayment_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:
WHOP_WEBHOOK_SECRET_HYGGE— global Hygge SaaS secret- Per-merchant
whop_webhook_secretfrom themerchantstable (looked up bycompany_id) - Optional fallback
WHOP_WEBHOOK_SECRETenv 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:
- Verify HMAC → 401 if invalid
- Record the receipt in
gdpr_audit_log(UNIQUE onshopify_webhook_idfor idempotency) - If duplicate → return 200 immediately
- Run topic-specific logic
- 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#
| Source | Mechanism |
|---|---|
| Shopify webhooks | X-Shopify-Webhook-Id → gdpr_audit_log.shopify_webhook_id UNIQUE constraint |
| Whop SaaS membership events | whop_subscription_id + status + period_end triple — exact-match update is skipped |
| Whop buyer payment | None 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:
- Logging the error with full context
- Marking the audit row
status='failed'(for GDPR webhooks) - Still returning
200to 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.