Back to Blog
Shopifyecommercetutorialintegration

Shopify Webhook Integration: Order Events, HMAC Verification, and Production Gotchas

Shopify's webhook system powers order fulfillment, inventory sync, and customer lifecycle automation. Here's how to integrate it correctly — including the quirks that will catch you in production.

F
Finn Eriksson
Payments Engineer
January 22, 2026
12 min read

Shopify sends webhooks for nearly every event in your store lifecycle: orders created, payment collected, fulfillment shipped, inventory updated, customers registered. If you're building an app on the Shopify platform — or integrating a Shopify store with your own systems — webhooks are your primary data channel.

Shopify's webhook docs are decent but gloss over the production edge cases that burn teams. This guide covers the integration in full, including the gotchas that appear only at scale.


Shopify Webhook Architecture

Shopify offers two webhook mechanisms:

REST Admin Webhooks — the classic approach. Register webhook subscriptions via the Admin API, specifying a topic and destination URL. Shopify calls your endpoint when events occur.

Event Bridge Webhooks (via Shopify Flow) — newer, more for no-code automation. Not covered here.

For most integrations, you want REST Admin Webhooks with HMAC-SHA256 verification.


Registering Webhooks

Register webhooks via the Admin API:

bash
curl -X POST "https://your-store.myshopify.com/admin/api/2024-01/webhooks.json" \
  -H "X-Shopify-Access-Token: your-access-token" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook": {
      "topic": "orders/create",
      "address": "https://your-endpoint.com/webhooks/shopify",
      "format": "json"
    }
  }'

Or in your Shopify app setup code during OAuth installation.

Key topics for order management:

TopicWhen It Fires
orders/createNew order placed
orders/updatedAny order field changes
orders/paidPayment captured
orders/cancelledOrder cancelled
orders/fulfilledAll line items fulfilled
orders/partially_fulfilledSome line items fulfilled
fulfillments/createFulfillment record created
fulfillments/updateTracking number added, status changed
inventory_levels/updateStock quantity changed
customers/createNew customer account created
refunds/createRefund issued

HMAC Signature Verification

Shopify signs every webhook with X-Shopify-Hmac-Sha256. The value is a base64-encoded HMAC-SHA256 (different from GitHub's hex-encoded format — this trips people up constantly).

go
package shopify

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"net/http"
)

func VerifyWebhook(r *http.Request, body []byte, sharedSecret string) bool {
	signature := r.Header.Get("X-Shopify-Hmac-Sha256")
	if signature == "" {
		return false
	}

	mac := hmac.New(sha256.New, []byte(sharedSecret))
	mac.Write(body)
	expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))

	// Constant-time comparison
	return hmac.Equal([]byte(expected), []byte(signature))
}

The shared secret is the webhook's secret, found in Partners Dashboard → App → Webhooks, or in the webhook subscription response when you create it via API.

Do not use hmac.Equal with base64-decoded bytes — compare the base64 strings directly to avoid encoding bugs.


The Order Created Event: What's Actually in the Payload

The orders/create payload is large. Here's what matters:

json
{
  "id": 820982911946154500,
  "email": "customer@example.com",
  "created_at": "2024-01-15T10:00:00-05:00",
  "financial_status": "paid",
  "fulfillment_status": null,
  "total_price": "149.99",
  "currency": "USD",
  "line_items": [
    {
      "id": 866550311766439020,
      "product_id": 632910392,
      "variant_id": 808950810,
      "quantity": 2,
      "price": "49.99",
      "title": "Wool Coat",
      "sku": "COAT-M-BLK"
    }
  ],
  "shipping_address": {
    "first_name": "Bob",
    "last_name": "Norman",
    "address1": "Chestnut Street 92",
    "city": "Louisville",
    "province": "Kentucky",
    "country": "US",
    "zip": "40202"
  }
}

financial_status matters: An order can be created without payment captured (for manual payment methods, invoicing, etc.). If you're triggering fulfillment on orders/create, check that financial_status == "paid" first. Better yet, trigger on orders/paid.


Production Gotchas

Gotcha 1: The 5-Second Timeout

Shopify's webhook delivery timeout is 5 seconds. Your endpoint must return 200 within 5 seconds or Shopify marks the delivery as failed and schedules a retry.

This is tight. If you're doing any non-trivial processing (inventory check, CRM update, email send), you will exceed this occasionally.

Fix: Queue immediately, process asynchronously.

go
func HandleShopify(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)

    if !shopify.VerifyWebhook(r, body, secret) {
        w.WriteHeader(401)
        return
    }

    // Enqueue for async processing — do NOT process here
    queue.Push(body)
    w.WriteHeader(200) // Respond within 200ms
}

Gotcha 2: Shopify Retries 19 Times Over 48 Hours

Shopify is aggressive about retries. If your endpoint fails, Shopify will retry 19 times over 48 hours. After 19 consecutive failures, Shopify automatically disables the webhook subscription and emails your account.

This has two implications:

  1. Don't let retries accumulate — fix failures quickly
  2. An endpoint that's down for 48 hours will have its webhook subscription auto-disabled. You'll miss events after it comes back up.

Monitoring: Use GetHook's webhook delivery logs to detect early retry accumulation, before Shopify disables your subscription.

Gotcha 3: Duplicate Events Are Common

Shopify retries aren't just for failures. Network timeouts, even if your endpoint processed the event, result in retries. In practice, you should expect 2–5% of events to arrive more than once.

Use Shopify's X-Shopify-Webhook-Id header as your idempotency key:

go
webhookID := r.Header.Get("X-Shopify-Webhook-Id")
if alreadyProcessed(webhookID) {
    w.WriteHeader(200) // Ack without reprocessing
    return
}

Gotcha 4: orders/updated Fires Constantly

Every time Shopify touches an order — adding a tag, updating fulfillment status, adding a note — orders/updated fires. During a fulfillment batch, a single order can trigger 5–10 orders/updated events in rapid succession.

If you're doing a full sync on every update, this will overload your system. Instead, compare the updated_at timestamp and skip if the order is already at the current version, or use a field-level diff to only process meaningful changes.

Gotcha 5: Order IDs Are 64-bit Integers

Shopify order IDs (and product IDs, variant IDs) are 64-bit integers that don't fit in a 32-bit int. In JavaScript, these get silently corrupted when parsed as number (JS numbers are 64-bit floats with 53-bit mantissa).

javascript
// ❌ Loses precision for large IDs
const order = JSON.parse(body);
const orderId = order.id; // May be wrong for large IDs

// ✅ Parse as string
const order = JSON.parse(body, (key, value) => {
  if (key === 'id' && typeof value === 'number') {
    return String(value); // Use BigInt or string
  }
  return value;
});

In Go, use int64. In Python, standard json.loads handles this correctly. In JavaScript, consider json-bigint.


Handling Refunds and Cancellations

Refunds and cancellations require special care because they can trigger reverse operations in your downstream systems.

go
type RefundPayload struct {
    ID      int64  `json:"id"`
    OrderID int64  `json:"order_id"`
    Refunds []struct {
        Amount     string `json:"amount"`
        Currency   string `json:"currency"`
        CreatedAt  string `json:"created_at"`
        LineItems  []struct {
            Quantity  int    `json:"quantity"`
            LineItemID int64 `json:"line_item_id"`
        } `json:"refund_line_items"`
    } `json:"refunds"`
}

Before processing a refunds/create event:

  1. Check if the original orders/paid event was processed — you can't refund what wasn't charged
  2. Use the refund ID as an idempotency key — Shopify can fire this multiple times
  3. Verify the refund amount doesn't exceed the original payment before crediting

Local Development Setup

Point your Shopify webhook at a GetHook source URL during development. This gives you:

  • Events delivered to your laptop without a tunnel
  • Full replay capability — no re-triggering Shopify
  • A log of every event payload for debugging

Alternatively, use the Shopify CLI:

bash
shopify app dev

This creates a local tunnel and registers webhook subscriptions automatically in your development store.


Security Checklist for Production

  • Verify X-Shopify-Hmac-Sha256 on every request (base64, not hex)
  • Use constant-time comparison for HMAC values
  • Store the webhook shared secret in a secret manager, not env vars
  • Return 200 within 5 seconds — queue all processing
  • Implement idempotency using X-Shopify-Webhook-Id
  • Monitor for retry accumulation — alert before Shopify auto-disables your subscription
  • Handle 64-bit integer IDs correctly for your language
  • Set up DLQ alerts for any events that exhaust all retries

Shopify webhooks power some of the most critical business workflows in e-commerce. Getting the integration right the first time is worth the investment.

Start receiving Shopify webhooks with GetHook →

Stop losing webhook events.

GetHook gives you reliable delivery, automatic retry, and full observability — in minutes.