All Skills

Comprehensive patterns for building Shopify apps with Go backend (Fiber v3) - OAuth, webhooks, GraphQL/REST APIs, App Bridge, and GDPR compliance

T
$npx skills add TatTran22/claude-code-shopify --skill shopify-integration-patterns

Shopify Integration Patterns

Comprehensive guide for building Shopify apps with Go backend using Fiber v3. Covers authentication, API integration, webhook handling, and compliance.

Core Concepts

Shopify App Architecture

+-------------------------------------------------------------+
|                     Shopify Admin                           |
|  +------------------------------------------------------+   |
|  |         Embedded App (iframe)                        |   |
|  |  React + Vite + Polaris Web Components + App Bridge  |   |
|  +-------------------------+----------------------------+   |
|                            | API Calls                      |
+----------------------------+--------------------------------+
                             |
                             v
               +---------------------------+
               |   Go Backend (Fiber)      |
               |  +---------------------+  |
               |  | OAuth Handler       |  |
               |  | Session Manager     |  |
               |  | API Middleware      |  |
               |  | Webhook Receivers   |  |
               |  +---------------------+  |
               +-----------+---------------+
                           |
             +-------------+-------------+
             v             v             v
        PostgreSQL      Redis       RabbitMQ
        (Sessions)     (Cache)     (Webhooks)

1. OAuth Authentication

1.1 OAuth Flow Implementation

// internal/shopify/oauth.go
package shopify

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "net/http"
    "net/url"
    "sort"
    "strings"
)

type OAuthConfig struct {
    ClientID     string
    ClientSecret string
    RedirectURI  string
    Scopes       []string
}

// VerifyHMAC verifies the HMAC signature from Shopify
func (c *OAuthConfig) VerifyHMAC(queryParams url.Values) bool {
    receivedHMAC := queryParams.Get("hmac")
    queryParams.Del("hmac")

    // Build message from query params (sorted alphabetically)
    var params []string
    for key, values := range queryParams {
        for _, value := range values {
            params = append(params, fmt.Sprintf("%s=%s", key, value))
        }
    }
    sort.Strings(params)
    message := strings.Join(params, "&")

    // Compute HMAC
    mac := hmac.New(sha256.New, []byte(c.ClientSecret))
    mac.Write([]byte(message))
    expectedHMAC := hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(receivedHMAC), []byte(expectedHMAC))
}

// GetAuthorizationURL generates the OAuth authorization URL
func (c *OAuthConfig) GetAuthorizationURL(shop, state, nonce string) string {
    params := url.Values{}
    params.Set("client_id", c.ClientID)
    params.Set("scope", strings.Join(c.Scopes, ","))
    params.Set("redirect_uri", c.RedirectURI)
    params.Set("state", state)
    params.Set("grant_options[]", "per-user")

    return fmt.Sprintf("https://%s/admin/oauth/authorize?%s", shop, params.Encode())
}

// ExchangeCodeForToken exchanges authorization code for access token
func (c *OAuthConfig) ExchangeCodeForToken(shop, code string) (*TokenResponse, error) {
    data := url.Values{}
    data.Set("client_id", c.ClientID)
    data.Set("client_secret", c.ClientSecret)
    data.Set("code", code)

    tokenURL := fmt.Sprintf("https://%s/admin/oauth/access_token", shop)

    resp, err := http.PostForm(tokenURL, data)
    if err != nil {
        return nil, fmt.Errorf("token exchange failed: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("token exchange returned %d", resp.StatusCode)
    }

    var tokenResp TokenResponse
    if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
        return nil, fmt.Errorf("failed to decode token response: %w", err)
    }

    return &tokenResp, nil
}

type TokenResponse struct {
    AccessToken string `json:"access_token"`
    Scope       string `json:"scope"`
}

1.2 OAuth Handler (Fiber)

// internal/handler/oauth_handler.go
package handler

import (
    "crypto/rand"
    "encoding/base64"
    "fmt"
    "os"
    "strings"

    "github.com/gofiber/fiber/v3"
)

type OAuthHandler struct {
    config      *shopify.OAuthConfig
    sessionRepo SessionRepository
}

func NewOAuthHandler(config *shopify.OAuthConfig, sessionRepo SessionRepository) *OAuthHandler {
    return &OAuthHandler{
        config:      config,
        sessionRepo: sessionRepo,
    }
}

// HandleInstall initiates OAuth flow
func (h *OAuthHandler) HandleInstall(c fiber.Ctx) error {
    shop := c.Query("shop")
    if shop == "" {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "missing shop parameter",
        })
    }

    // Validate shop domain
    if !isValidShopDomain(shop) {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "invalid shop domain",
        })
    }

    // Generate state for CSRF protection
    state, err := generateRandomString(32)
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "failed to generate state",
        })
    }

    // Store state in session (with expiry)
    if err := h.sessionRepo.StoreOAuthState(c.Context(), shop, state); err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "failed to store state",
        })
    }

    // Redirect to Shopify OAuth
    authURL := h.config.GetAuthorizationURL(shop, state, "")
    return c.Redirect().To(authURL)
}

// HandleCallback handles OAuth callback
func (h *OAuthHandler) HandleCallback(c fiber.Ctx) error {
    // Get query parameters as url.Values
    queryParams := make(url.Values)
    c.Context().QueryArgs().VisitAll(func(key, value []byte) {
        queryParams.Add(string(key), string(value))
    })

    // Verify HMAC
    if !h.config.VerifyHMAC(queryParams) {
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": "invalid HMAC signature",
        })
    }

    shop := c.Query("shop")
    code := c.Query("code")
    state := c.Query("state")

    // Verify state (CSRF protection)
    storedState, err := h.sessionRepo.GetOAuthState(c.Context(), shop)
    if err != nil || storedState != state {
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": "invalid state parameter",
        })
    }

    // Exchange code for token
    tokenResp, err := h.config.ExchangeCodeForToken(shop, code)
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "failed to exchange token",
        })
    }

    // Store access token
    session := &Session{
        Shop:        shop,
        AccessToken: tokenResp.AccessToken,
        Scope:       tokenResp.Scope,
    }

    if err := h.sessionRepo.SaveSession(c.Context(), session); err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "failed to save session",
        })
    }

    // Redirect to app
    appURL := fmt.Sprintf("https://%s/admin/apps/%s", shop, os.Getenv("APP_HANDLE"))
    return c.Redirect().To(appURL)
}

func generateRandomString(length int) (string, error) {
    bytes := make([]byte, length)
    if _, err := rand.Read(bytes); err != nil {
        return "", err
    }
    return base64.URLEncoding.EncodeToString(bytes), nil
}

func isValidShopDomain(shop string) bool {
    return strings.HasSuffix(shop, ".myshopify.com")
}

1.3 Session Token Authentication (Embedded Apps)

For embedded apps using App Bridge, use session tokens instead of OAuth for API requests:

// internal/middleware/session_token.go
package middleware

import (
    "fmt"
    "strings"

    "github.com/gofiber/fiber/v3"
    "github.com/golang-jwt/jwt/v5"
)

type SessionTokenMiddleware struct {
    clientSecret string
}

func NewSessionTokenMiddleware(clientSecret string) *SessionTokenMiddleware {
    return &SessionTokenMiddleware{clientSecret: clientSecret}
}

func (m *SessionTokenMiddleware) Verify(c fiber.Ctx) error {
    // Extract token from Authorization header
    authHeader := c.Get("Authorization")
    if authHeader == "" {
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": "missing authorization header",
        })
    }

    tokenString := strings.TrimPrefix(authHeader, "Bearer ")

    // Parse and validate JWT
    token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
        }
        return []byte(m.clientSecret), nil
    })

    if err != nil || !token.Valid {
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": "invalid session token",
        })
    }

    claims, ok := token.Claims.(jwt.MapClaims)
    if !ok {
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": "invalid token claims",
        })
    }

    // Extract shop domain (dest field)
    dest, ok := claims["dest"].(string)
    if !ok {
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": "missing dest claim",
        })
    }

    shop := strings.TrimPrefix(dest, "https://")

    // Add shop to Locals
    c.Locals("shop", shop)

    return c.Next()
}

// GetShop retrieves shop from Fiber context
func GetShop(c fiber.Ctx) string {
    shop, _ := c.Locals("shop").(string)
    return shop
}

2. GraphQL Admin API

2.1 GraphQL Client

// internal/shopify/graphql_client.go
package shopify

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
)

type GraphQLClient struct {
    shop        string
    accessToken string
    httpClient  *http.Client
}

func NewGraphQLClient(shop, accessToken string) *GraphQLClient {
    return &GraphQLClient{
        shop:        shop,
        accessToken: accessToken,
        httpClient:  &http.Client{},
    }
}

type GraphQLRequest struct {
    Query     string                 `json:"query"`
    Variables map[string]interface{} `json:"variables,omitempty"`
}

type GraphQLResponse struct {
    Data   json.RawMessage `json:"data"`
    Errors []GraphQLError  `json:"errors,omitempty"`
}

type GraphQLError struct {
    Message string `json:"message"`
    Path    []any  `json:"path,omitempty"`
}

func (c *GraphQLClient) Query(ctx context.Context, query string, variables map[string]interface{}, result interface{}) error {
    req := GraphQLRequest{
        Query:     query,
        Variables: variables,
    }

    body, err := json.Marshal(req)
    if err != nil {
        return fmt.Errorf("failed to marshal request: %w", err)
    }

    url := fmt.Sprintf("https://%s/admin/api/2024-01/graphql.json", c.shop)
    httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
    if err != nil {
        return fmt.Errorf("failed to create request: %w", err)
    }

    httpReq.Header.Set("Content-Type", "application/json")
    httpReq.Header.Set("X-Shopify-Access-Token", c.accessToken)

    resp, err := c.httpClient.Do(httpReq)
    if err != nil {
        return fmt.Errorf("request failed: %w", err)
    }
    defer resp.Body.Close()

    // Check for rate limiting
    if resp.StatusCode == http.StatusTooManyRequests {
        return fmt.Errorf("rate limited: %s", resp.Header.Get("Retry-After"))
    }

    if resp.StatusCode != http.StatusOK {
        body, _ := io.ReadAll(resp.Body)
        return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body))
    }

    var graphQLResp GraphQLResponse
    if err := json.NewDecoder(resp.Body).Decode(&graphQLResp); err != nil {
        return fmt.Errorf("failed to decode response: %w", err)
    }

    if len(graphQLResp.Errors) > 0 {
        return fmt.Errorf("graphql errors: %v", graphQLResp.Errors)
    }

    if err := json.Unmarshal(graphQLResp.Data, result); err != nil {
        return fmt.Errorf("failed to unmarshal data: %w", err)
    }

    return nil
}

2.2 Common GraphQL Queries

// internal/shopify/queries.go
package shopify

const (
    // Get shop information
    QueryShopInfo = `
        query {
            shop {
                id
                name
                email
                myshopifyDomain
                plan {
                    displayName
                }
            }
        }
    `

    // Get products with pagination
    QueryProducts = `
        query GetProducts($first: Int!, $after: String) {
            products(first: $first, after: $after) {
                edges {
                    node {
                        id
                        title
                        handle
                        status
                        variants(first: 10) {
                            edges {
                                node {
                                    id
                                    title
                                    price
                                    sku
                                }
                            }
                        }
                    }
                    cursor
                }
                pageInfo {
                    hasNextPage
                    endCursor
                }
            }
        }
    `

    // Create product mutation
    MutationCreateProduct = `
        mutation CreateProduct($input: ProductInput!) {
            productCreate(input: $input) {
                product {
                    id
                    title
                    handle
                }
                userErrors {
                    field
                    message
                }
            }
        }
    `

    // Update metafield
    MutationUpdateMetafield = `
        mutation UpdateMetafield($metafields: [MetafieldsSetInput!]!) {
            metafieldsSet(metafields: $metafields) {
                metafields {
                    id
                    namespace
                    key
                    value
                }
                userErrors {
                    field
                    message
                }
            }
        }
    `
)

// Example usage
func (s *ShopifyService) GetProducts(ctx context.Context, shop string, first int, after *string) (*ProductConnection, error) {
    client := s.getClient(shop)

    variables := map[string]interface{}{
        "first": first,
    }
    if after != nil {
        variables["after"] = *after
    }

    var result struct {
        Products ProductConnection `json:"products"`
    }

    if err := client.Query(ctx, QueryProducts, variables, &result); err != nil {
        return nil, err
    }

    return &result.Products, nil
}

2.3 Rate Limiting with Retries

// internal/shopify/rate_limiter.go
package shopify

import (
    "context"
    "strings"
    "time"
)

type RateLimitedClient struct {
    client     *GraphQLClient
    maxRetries int
}

func NewRateLimitedClient(client *GraphQLClient, maxRetries int) *RateLimitedClient {
    return &RateLimitedClient{
        client:     client,
        maxRetries: maxRetries,
    }
}

func (c *RateLimitedClient) QueryWithRetry(ctx context.Context, query string, variables map[string]interface{}, result interface{}) error {
    var lastErr error

    for attempt := 0; attempt <= c.maxRetries; attempt++ {
        err := c.client.Query(ctx, query, variables, result)
        if err == nil {
            return nil
        }

        // Check if rate limited
        if isRateLimitError(err) {
            retryAfter := extractRetryAfter(err)
            if retryAfter > 0 {
                select {
                case <-time.After(retryAfter):
                    continue
                case <-ctx.Done():
                    return ctx.Err()
                }
            }

            // Exponential backoff if no retry-after header
            backoff := time.Duration(1<<uint(attempt)) * time.Second
            select {
            case <-time.After(backoff):
                continue
            case <-ctx.Done():
                return ctx.Err()
            }
        }

        lastErr = err
        break
    }

    return lastErr
}

func isRateLimitError(err error) bool {
    return err != nil && (
        strings.Contains(err.Error(), "rate limited") ||
        strings.Contains(err.Error(), "429") ||
        strings.Contains(err.Error(), "THROTTLED"))
}

func extractRetryAfter(err error) time.Duration {
    // Parse retry-after from error message
    // This is simplified - adjust based on actual error format
    return 2 * time.Second
}

3. Webhook Handling

3.1 Webhook Verification

// internal/shopify/webhook.go
package shopify

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"

    "github.com/gofiber/fiber/v3"
)

type WebhookVerifier struct {
    secret string
}

func NewWebhookVerifier(secret string) *WebhookVerifier {
    return &WebhookVerifier{secret: secret}
}

// VerifyWebhook verifies the HMAC signature of a webhook request
func (v *WebhookVerifier) VerifyWebhook(c fiber.Ctx, body []byte) bool {
    hmacHeader := c.Get("X-Shopify-Hmac-Sha256")
    if hmacHeader == "" {
        return false
    }

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

    return hmac.Equal([]byte(hmacHeader), []byte(expectedMAC))
}

// WebhookMiddleware verifies webhook authenticity
func (v *WebhookVerifier) WebhookMiddleware(c fiber.Ctx) error {
    // Get raw body
    body := c.Body()

    // Verify HMAC
    if !v.VerifyWebhook(c, body) {
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": "invalid webhook signature",
        })
    }

    // Store body and metadata in Locals for handler
    c.Locals("webhook_body", body)
    c.Locals("shop_domain", c.Get("X-Shopify-Shop-Domain"))
    c.Locals("webhook_topic", c.Get("X-Shopify-Topic"))
    c.Locals("webhook_id", c.Get("X-Shopify-Webhook-Id"))

    return c.Next()
}

// Helper functions to get webhook data from context
func GetWebhookBody(c fiber.Ctx) []byte {
    body, _ := c.Locals("webhook_body").([]byte)
    return body
}

func GetWebhookShop(c fiber.Ctx) string {
    shop, _ := c.Locals("shop_domain").(string)
    return shop
}

func GetWebhookTopic(c fiber.Ctx) string {
    topic, _ := c.Locals("webhook_topic").(string)
    return topic
}

3.2 Webhook Handlers

// internal/handler/webhook_handler.go
package handler

import (
    "encoding/json"

    "github.com/gofiber/fiber/v3"
)

type WebhookHandler struct {
    queue WebhookQueue
}

func NewWebhookHandler(queue WebhookQueue) *WebhookHandler {
    return &WebhookHandler{queue: queue}
}

// HandleOrderCreate handles orders/create webhook
func (h *WebhookHandler) HandleOrderCreate(c fiber.Ctx) error {
    body := shopify.GetWebhookBody(c)
    shop := shopify.GetWebhookShop(c)

    var order Order
    if err := json.Unmarshal(body, &order); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "invalid JSON",
        })
    }

    // Queue for async processing
    if err := h.queue.EnqueueOrderCreated(c.Context(), shop, &order); err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "failed to queue webhook",
        })
    }

    return c.SendStatus(fiber.StatusOK)
}

// HandleProductUpdate handles products/update webhook
func (h *WebhookHandler) HandleProductUpdate(c fiber.Ctx) error {
    body := shopify.GetWebhookBody(c)
    shop := shopify.GetWebhookShop(c)

    var product Product
    if err := json.Unmarshal(body, &product); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "invalid JSON",
        })
    }

    // Queue for async processing
    if err := h.queue.EnqueueProductUpdated(c.Context(), shop, &product); err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "failed to queue webhook",
        })
    }

    return c.SendStatus(fiber.StatusOK)
}

// HandleAppUninstalled handles app/uninstalled webhook (CRITICAL)
func (h *WebhookHandler) HandleAppUninstalled(c fiber.Ctx) error {
    shop := shopify.GetWebhookShop(c)

    // Delete shop data immediately (or queue for cleanup)
    if err := h.queue.EnqueueAppUninstalled(c.Context(), shop); err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "failed to process uninstall",
        })
    }

    return c.SendStatus(fiber.StatusOK)
}

3.3 GDPR Webhooks (MANDATORY)

// internal/handler/gdpr_webhook_handler.go
package handler

import (
    "encoding/json"

    "github.com/gofiber/fiber/v3"
)

type GDPRWebhookHandler struct {
    service GDPRService
}

func NewGDPRWebhookHandler(service GDPRService) *GDPRWebhookHandler {
    return &GDPRWebhookHandler{service: service}
}

// HandleCustomersDataRequest handles customers/data_request webhook
// MUST respond within 30 days with customer data
func (h *GDPRWebhookHandler) HandleCustomersDataRequest(c fiber.Ctx) error {
    body := shopify.GetWebhookBody(c)

    var request CustomerDataRequest
    if err := json.Unmarshal(body, &request); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "invalid JSON",
        })
    }

    // Queue for processing (respond to merchant within 30 days)
    if err := h.service.QueueDataRequest(c.Context(), &request); err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "failed to queue request",
        })
    }

    return c.SendStatus(fiber.StatusOK)
}

// HandleCustomersRedact handles customers/redact webhook
// MUST delete customer data within 30 days (if no other orders)
func (h *GDPRWebhookHandler) HandleCustomersRedact(c fiber.Ctx) error {
    body := shopify.GetWebhookBody(c)

    var request CustomerRedactRequest
    if err := json.Unmarshal(body, &request); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "invalid JSON",
        })
    }

    // Queue for deletion
    if err := h.service.QueueCustomerRedaction(c.Context(), &request); err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "failed to queue redaction",
        })
    }

    return c.SendStatus(fiber.StatusOK)
}

// HandleShopRedact handles shop/redact webhook
// MUST delete all shop data within 48 hours
func (h *GDPRWebhookHandler) HandleShopRedact(c fiber.Ctx) error {
    body := shopify.GetWebhookBody(c)

    var request ShopRedactRequest
    if err := json.Unmarshal(body, &request); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "invalid JSON",
        })
    }

    // Queue for immediate deletion (48-hour deadline)
    if err := h.service.QueueShopRedaction(c.Context(), &request); err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": "failed to queue redaction",
        })
    }

    return c.SendStatus(fiber.StatusOK)
}

type CustomerDataRequest struct {
    ShopID       int64   `json:"shop_id"`
    ShopDomain   string  `json:"shop_domain"`
    CustomerID   int64   `json:"customer_id"`
    OrdersToRedact []int64 `json:"orders_to_redact"`
}

type CustomerRedactRequest struct {
    ShopID       int64   `json:"shop_id"`
    ShopDomain   string  `json:"shop_domain"`
    CustomerID   int64   `json:"customer_id"`
    OrdersToRedact []int64 `json:"orders_to_redact"`
}

type ShopRedactRequest struct {
    ShopID     int64  `json:"shop_id"`
    ShopDomain string `json:"shop_domain"`
}

3.4 Webhook Registration

// internal/shopify/webhook_registration.go
package shopify

import (
    "context"
    "fmt"
    "os"
)

const MutationRegisterWebhook = `
    mutation webhookSubscriptionCreate($topic: WebhookSubscriptionTopic!, $webhookSubscription: WebhookSubscriptionInput!) {
        webhookSubscriptionCreate(
            topic: $topic
            webhookSubscription: $webhookSubscription
        ) {
            webhookSubscription {
                id
                topic
                endpoint {
                    __typename
                    ... on WebhookHttpEndpoint {
                        callbackUrl
                    }
                }
            }
            userErrors {
                field
                message
            }
        }
    }
`

func (s *ShopifyService) RegisterWebhook(ctx context.Context, shop, topic, callbackURL string) error {
    client := s.getClient(shop)

    variables := map[string]interface{}{
        "topic": topic,
        "webhookSubscription": map[string]interface{}{
            "callbackUrl": callbackURL,
            "format":      "JSON",
        },
    }

    var result struct {
        WebhookSubscriptionCreate struct {
            WebhookSubscription *WebhookSubscription `json:"webhookSubscription"`
            UserErrors          []UserError          `json:"userErrors"`
        } `json:"webhookSubscriptionCreate"`
    }

    if err := client.Query(ctx, MutationRegisterWebhook, variables, &result); err != nil {
        return fmt.Errorf("failed to register webhook: %w", err)
    }

    if len(result.WebhookSubscriptionCreate.UserErrors) > 0 {
        return fmt.Errorf("webhook registration errors: %v", result.WebhookSubscriptionCreate.UserErrors)
    }

    return nil
}

// Register all required webhooks after OAuth
func (s *ShopifyService) RegisterAllWebhooks(ctx context.Context, shop string) error {
    baseURL := os.Getenv("APP_URL")

    webhooks := []struct {
        topic string
        path  string
    }{
        {"ORDERS_CREATE", "/webhooks/orders/create"},
        {"ORDERS_UPDATED", "/webhooks/orders/update"},
        {"PRODUCTS_UPDATE", "/webhooks/products/update"},
        {"APP_UNINSTALLED", "/webhooks/app/uninstalled"},
        {"CUSTOMERS_DATA_REQUEST", "/webhooks/gdpr/customers_data_request"},
        {"CUSTOMERS_REDACT", "/webhooks/gdpr/customers_redact"},
        {"SHOP_REDACT", "/webhooks/gdpr/shop_redact"},
    }

    for _, wh := range webhooks {
        callbackURL := fmt.Sprintf("%s%s", baseURL, wh.path)
        if err := s.RegisterWebhook(ctx, shop, wh.topic, callbackURL); err != nil {
            return fmt.Errorf("failed to register %s: %w", wh.topic, err)
        }
    }

    return nil
}

3.5 Webhook Routes Setup

// cmd/api/main.go - Webhook routes setup
func setupWebhookRoutes(app *fiber.App, verifier *shopify.WebhookVerifier, handler *handler.WebhookHandler, gdprHandler *handler.GDPRWebhookHandler) {
    webhooks := app.Group("/webhooks")

    // Apply webhook verification middleware to all webhook routes
    webhooks.Use(verifier.WebhookMiddleware)

    // Order webhooks
    webhooks.Post("/orders/create", handler.HandleOrderCreate)
    webhooks.Post("/orders/update", handler.HandleOrderUpdate)

    // Product webhooks
    webhooks.Post("/products/update", handler.HandleProductUpdate)

    // App lifecycle
    webhooks.Post("/app/uninstalled", handler.HandleAppUninstalled)

    // GDPR mandatory webhooks
    gdpr := webhooks.Group("/gdpr")
    gdpr.Post("/customers_data_request", gdprHandler.HandleCustomersDataRequest)
    gdpr.Post("/customers_redact", gdprHandler.HandleCustomersRedact)
    gdpr.Post("/shop_redact", gdprHandler.HandleShopRedact)
}

4. App Bridge (Frontend Integration)

With Polaris Web Components, App Bridge uses direct initialization instead of React Providers.

4.1 App Bridge Setup (Direct Initialization)

// frontend/src/lib/app-bridge.ts
import { createApp, type ClientApplication } from '@shopify/app-bridge';
import { getSessionToken } from '@shopify/app-bridge/utilities';

let app: ClientApplication | null = null;

/**
 * Get or create the App Bridge instance.
 * Uses singleton pattern for consistent access across the app.
 */
export function getAppBridge(): ClientApplication {
  if (!app) {
    const host = new URLSearchParams(window.location.search).get('host');

    if (!host) {
      throw new Error('Missing host parameter. Access app from Shopify admin.');
    }

    app = createApp({
      apiKey: import.meta.env.VITE_SHOPIFY_API_KEY,
      host,
    });
  }
  return app;
}

/**
 * Get the current session token for API authentication.
 */
export async function getAuthToken(): Promise<string> {
  const appBridge = getAppBridge();
  return getSessionToken(appBridge);
}

/**
 * Check if the app is running in Shopify context.
 */
export function isShopifyContext(): boolean {
  return !!new URLSearchParams(window.location.search).get('host');
}

4.2 App Frame with Error Handling

// frontend/src/components/AppFrame.tsx
import { isShopifyContext } from '@/lib/app-bridge';

interface AppFrameProps {
  children: React.ReactNode;
}

export function AppFrame({ children }: AppFrameProps) {
  if (!isShopifyContext()) {
    return (
      <s-page title="Error">
        <s-section>
          <s-banner status="critical" heading="Access Error">
            <s-text>
              Missing host parameter. Please access this app from the Shopify admin.
            </s-text>
          </s-banner>
        </s-section>
      </s-page>
    );
  }

  return <>{children}</>;
}

4.3 Authenticated Fetch with Session Token

// frontend/src/hooks/useAuthenticatedFetch.ts
import { useCallback } from 'react';
import { getAuthToken } from '@/lib/app-bridge';

const API_URL = import.meta.env.VITE_API_URL;

export function useAuthenticatedFetch() {
  return useCallback(async (uri: string, options?: RequestInit) => {
    const sessionToken = await getAuthToken();
    const url = uri.startsWith('http') ? uri : `${API_URL}${uri}`;

    const response = await fetch(url, {
      ...options,
      headers: {
        ...options?.headers,
        'Authorization': `Bearer ${sessionToken}`,
        'Content-Type': 'application/json',
      },
    });

    if (!response.ok) {
      throw new Error(`Request failed: ${response.statusText}`);
    }

    return response;
  }, []);
}

4.4 Usage with TanStack Query

// frontend/src/hooks/useProducts.ts
import { useQuery } from '@tanstack/react-query';
import { useAuthenticatedFetch } from './useAuthenticatedFetch';

// Query key factory for consistent cache management
export const productKeys = {
  all: ['products'] as const,
  lists: () => [...productKeys.all, 'list'] as const,
  list: (filters: Record<string, string>) => [...productKeys.lists(), filters] as const,
  details: () => [...productKeys.all, 'detail'] as const,
  detail: (id: string) => [...productKeys.details(), id] as const,
};

export function useProducts(filters?: Record<string, string>) {
  const fetch = useAuthenticatedFetch();

  return useQuery({
    queryKey: productKeys.list(filters ?? {}),
    queryFn: async () => {
      const params = new URLSearchParams(filters);
      const response = await fetch(`/api/products?${params}`);
      return response.json();
    },
  });
}

4.5 App Bridge Actions (Toast, Navigation)

// frontend/src/hooks/useToast.ts
import { Toast } from '@shopify/app-bridge/actions';
import { getAppBridge } from '@/lib/app-bridge';

export function useToast() {
  const showToast = (message: string, options?: { isError?: boolean; duration?: number }) => {
    const app = getAppBridge();
    const toast = Toast.create(app, {
      message,
      duration: options?.duration ?? 3000,
      isError: options?.isError ?? false,
    });
    toast.dispatch(Toast.Action.SHOW);
  };

  return { showToast };
}

// frontend/src/hooks/useNavigation.ts
import { Redirect } from '@shopify/app-bridge/actions';
import { getAppBridge } from '@/lib/app-bridge';

export function useNavigation() {
  const navigateTo = (path: string) => {
    const app = getAppBridge();
    const redirect = Redirect.create(app);
    redirect.dispatch(Redirect.Action.APP, path);
  };

  const navigateToAdmin = (path: string) => {
    const app = getAppBridge();
    const redirect = Redirect.create(app);
    redirect.dispatch(Redirect.Action.ADMIN_PATH, path);
  };

  return { navigateTo, navigateToAdmin };
}

5. Metafields Management

5.1 Set Metafields

// internal/shopify/metafield.go
package shopify

import (
    "context"
    "fmt"
)

func (s *ShopifyService) SetProductMetafield(ctx context.Context, shop, productID, namespace, key, value, valueType string) error {
    client := s.getClient(shop)

    variables := map[string]interface{}{
        "metafields": []map[string]interface{}{
            {
                "ownerId":   productID,
                "namespace": namespace,
                "key":       key,
                "value":     value,
                "type":      valueType, // e.g., "single_line_text_field", "json", "number_integer"
            },
        },
    }

    var result struct {
        MetafieldsSet struct {
            Metafields []Metafield `json:"metafields"`
            UserErrors []UserError `json:"userErrors"`
        } `json:"metafieldsSet"`
    }

    if err := client.Query(ctx, MutationUpdateMetafield, variables, &result); err != nil {
        return fmt.Errorf("failed to set metafield: %w", err)
    }

    if len(result.MetafieldsSet.UserErrors) > 0 {
        return fmt.Errorf("metafield errors: %v", result.MetafieldsSet.UserErrors)
    }

    return nil
}

6. Best Practices

DO

// Always verify webhook HMAC signatures
func (v *WebhookVerifier) VerifyWebhook(c fiber.Ctx, body []byte) bool {
    hmacHeader := c.Get("X-Shopify-Hmac-Sha256")
    mac := hmac.New(sha256.New, []byte(v.secret))
    mac.Write(body)
    expectedMAC := base64.StdEncoding.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(hmacHeader), []byte(expectedMAC))
}

// Handle rate limiting with exponential backoff
func (c *RateLimitedClient) QueryWithRetry(ctx context.Context, query string) error {
    for attempt := 0; attempt <= c.maxRetries; attempt++ {
        err := c.client.Query(ctx, query, nil, nil)
        if err == nil {
            return nil
        }
        if isRateLimitError(err) {
            backoff := time.Duration(1<<uint(attempt)) * time.Second
            time.Sleep(backoff)
            continue
        }
        return err
    }
    return fmt.Errorf("max retries exceeded")
}

// Use context for cancellation
func (s *ShopifyService) FetchProducts(ctx context.Context, shop string) error {
    client := s.getClient(shop)
    return client.Query(ctx, QueryProducts, nil, nil)
}

// Queue webhooks for async processing
func (h *WebhookHandler) HandleOrderCreate(c fiber.Ctx) error {
    h.queue.Enqueue(c.Context(), order)
    return c.SendStatus(fiber.StatusOK) // Respond immediately
}

// Register GDPR webhooks (MANDATORY for public apps)
webhooks := []string{
    "CUSTOMERS_DATA_REQUEST",
    "CUSTOMERS_REDACT",
    "SHOP_REDACT",
}

// Validate shop domain before OAuth
func isValidShopDomain(shop string) bool {
    return strings.HasSuffix(shop, ".myshopify.com")
}

DON'T

// Never skip webhook verification
func HandleWebhook(c fiber.Ctx) error {
    // DANGEROUS: Processing unverified webhooks
    body := c.Body()
    processWebhook(body)
    return nil
}

// Don't expose client secret in frontend
// Store in backend environment variables only
const ClientSecret = "shpss_abc123" // WRONG

// Don't process webhooks synchronously
func HandleOrderCreate(c fiber.Ctx) error {
    // SLOW: Blocks webhook response
    processOrder(order)
    time.Sleep(5 * time.Second)
    return c.SendStatus(fiber.StatusOK)
}

// Don't ignore rate limiting
func FetchAllProducts() {
    // DANGEROUS: No rate limit handling
    for {
        client.Query(ctx, query, nil, nil)
    }
}

// Don't forget to handle app/uninstalled webhook
// MUST clean up shop data when app is uninstalled

// Don't store access tokens in plain text
// Encrypt sensitive data in database

7. Testing Patterns

7.1 Mock Shopify Client

// internal/shopify/mock_client.go
package shopify

import "context"

type MockGraphQLClient struct {
    QueryFunc func(ctx context.Context, query string, variables map[string]interface{}, result interface{}) error
}

func (m *MockGraphQLClient) Query(ctx context.Context, query string, variables map[string]interface{}, result interface{}) error {
    if m.QueryFunc != nil {
        return m.QueryFunc(ctx, query, variables, result)
    }
    return nil
}

7.2 Test Webhook Verification

// internal/shopify/webhook_test.go
package shopify_test

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "io"
    "net/http/httptest"
    "strings"
    "testing"

    "github.com/gofiber/fiber/v3"
    "github.com/stretchr/testify/assert"
)

func TestWebhookVerification(t *testing.T) {
    secret := "test-secret"
    verifier := shopify.NewWebhookVerifier(secret)

    tests := []struct {
        name      string
        body      []byte
        hmac      string
        wantValid bool
    }{
        {
            name:      "valid webhook",
            body:      []byte(`{"id": 123}`),
            hmac:      computeHMAC(t, secret, []byte(`{"id": 123}`)),
            wantValid: true,
        },
        {
            name:      "invalid hmac",
            body:      []byte(`{"id": 123}`),
            hmac:      "invalid-hmac",
            wantValid: false,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            app := fiber.New()
            app.Post("/webhook", func(c fiber.Ctx) error {
                valid := verifier.VerifyWebhook(c, tt.body)
                if valid != tt.wantValid {
                    t.Errorf("VerifyWebhook() = %v, want %v", valid, tt.wantValid)
                }
                return nil
            })

            req := httptest.NewRequest("POST", "/webhook", strings.NewReader(string(tt.body)))
            req.Header.Set("X-Shopify-Hmac-Sha256", tt.hmac)
            req.Header.Set("Content-Type", "application/json")

            resp, err := app.Test(req)
            assert.NoError(t, err)
            assert.Equal(t, 200, resp.StatusCode)
        })
    }
}

func computeHMAC(t *testing.T, secret string, body []byte) string {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}

8. Common Patterns

Pattern 1: Pagination

func (s *ShopifyService) FetchAllProducts(ctx context.Context, shop string) ([]Product, error) {
    var allProducts []Product
    var cursor *string

    for {
        conn, err := s.GetProducts(ctx, shop, 50, cursor)
        if err != nil {
            return nil, err
        }

        for _, edge := range conn.Edges {
            allProducts = append(allProducts, edge.Node)
        }

        if !conn.PageInfo.HasNextPage {
            break
        }

        cursor = &conn.PageInfo.EndCursor
    }

    return allProducts, nil
}

Pattern 2: Bulk Operations

const MutationBulkOperationRunQuery = `
    mutation {
        bulkOperationRunQuery(
            query: """
                {
                    products {
                        edges {
                            node {
                                id
                                title
                            }
                        }
                    }
                }
            """
        ) {
            bulkOperation {
                id
                status
            }
            userErrors {
                field
                message
            }
        }
    }
`

Quick Reference

Required Webhooks

  • APP_UNINSTALLED - Clean up shop data
  • CUSTOMERS_DATA_REQUEST - GDPR data request (30 days)
  • CUSTOMERS_REDACT - GDPR customer deletion (30 days)
  • SHOP_REDACT - GDPR shop deletion (48 hours)

OAuth Scopes

scopes := []string{
    "read_products",
    "write_products",
    "read_orders",
    "read_customers",
}

API Versions

Rate Limits

  • REST API: 2 requests/second (bucket-based)
  • GraphQL: Points-based (max 1000 points/second)
  • Webhook delivery: 1 per second per endpoint

Resources