Gareth Wilson Gareth Wilson

Guide to Loop Returns Webhooks: Features and Best Practices

Published


Loop runs returns and exchanges for Shopify merchants. Shoppers start a return in Loop's portal, Loop generates labels, tracks the parcel back, restocks items and issues refunds, exchanges or gift cards. Loop webhooks send each of those steps to your systems as it happens, so your warehouse or ERP can act on a return without polling Loop's API.

This guide covers how Loop webhook subscriptions are built from topics and triggers, how to verify the X-Loop-Signature header, how Loop retries failed deliveries, and the best practices for running a Loop receiver in production.

What are Loop Returns webhooks?

A Loop webhook subscription has a topic and a trigger. The topic decides what data the payload contains, such as a return, a label or a gift card. The trigger is the event in Loop that causes the webhook to be sent, such as a return being created or a label being updated.

When the trigger fires, Loop sends an HTTP POST with a JSON body to your URL. Each request carries an X-Loop-Signature header holding the base64 HMAC-SHA256 of the request body, keyed with that webhook's secret.

Loop Returns webhook features

FeatureDetails
ConfigurationDeveloper Tools page of the Loop Admin
Subscription modelOne topic plus one trigger per subscription
Signature headerX-Loop-Signature
SchemeHMAC-SHA256, standard base64
Signed contentThe raw request body
SecretOne per webhook, shown on the Developer Tools page
Replay windowNone. No timestamp is sent or signed
Response window200, 202 or 204 within 10 seconds
RetriesFive (six attempts in total), exponential backoff with jitter
Retryable codes0 (network), 429, 500, 502, 503, 504
retry-afterHonoured up to 30 minutes; longer values fail the webhook
Delivery guaranteeAt least once
Payload changesNew fields can appear without notice, additive only
Source IPsNot published

Common events

The body's trigger field names the event, and topic names the payload type.

TriggerTopicFires when
return.createdReturnA new return was submitted in the customer returns portal
return.updatedReturnA return was updated, including state changes such as open to closed, cancelled or expired
return.closedReturnA return's state has been updated to closed
return.processing.failedReturn processingQueued return processing failed to complete
label.createdLabelA shipping label has been created
label.updatedLabelA shipping label has been updated, for example from new to in transit
restock.requestedRestockAn item in the return has been restocked in Shopify
giftcard.requestedGift cardA gift card has been requested as part of an exchange
shipment.processedHappy Returns shipmentA Happy Returns shipment has been processed by Loop

Two further topics serve label and shipping providers that integrate with Loop:

TriggerTopicFires when
label.request.issuedLabel requestLoop needs a label for this return
label.request.cancelledLabel requestA previously issued label request was cancelled
ship-by-loop.services.requestedShip by Loop shipmentA shipment was created that this provider can fulfil
ship-by-loop.purchase.requestedShip by Loop shipmentServices have been selected and are ready for purchase
ship-by-loop.cancellation.requestedShip by Loop shipmentThe shipment was cancelled

Pick topics that fit their triggers. Loop warns that a mismatched pair can deliver an empty payload: the label topic with the return created trigger, for example, may arrive before any label exists.

Bodies are flat objects specific to each topic. A Return topic payload starts with topic, trigger and shop_id, followed by the return itself: id, state, created_at, totals, customer, line_items, exchanges, labels and more. Here is a trimmed ship-by-loop.services.requested example from Loop's schema:

{
  "topic": "ship-by-loop.shipment",
  "trigger": "ship-by-loop.services.requested",
  "shop_id": 4321,
  "shipment": {
    "id": "ff8a4470-41e0-4cc3-9bfe-44564f4286b7",
    "type": "return",
    "status": "draft",
    "origin": { "name": "Jane Shopper", "city": "Columbus", "state": "OH", "country": "US" },
    "destination": { "name": "Returns Processing", "city": "Reno", "state": "NV", "country": "US" },
    "packages": [
      {
        "id": "900a8671-35ad-4743-9b6c-2540d3bb1faf",
        "weight": { "value": 24, "unit": "oz" }
      }
    ]
  }
}

The Return topic's schema says trigger is included for subscriptions to that topic with more than one trigger, while every topic schema lists it as a field. Read trigger when it is present, and fall back to the subscription you created when it is not.

Setting up Loop Returns webhooks

  1. Sign in to the Loop Admin and open Developer Tools (admin.loopreturns.com/settings/developers).
  2. Create a webhook subscription. Choose the topic and the trigger, and enter your HTTPS endpoint URL.
  3. Copy the webhook's secret from the same page and store it server-side, for example as LOOP_WEBHOOK_SECRET. Each subscription has its own secret.
  4. Repeat for every topic and trigger pair you need.

Loop suggests pointing a new subscription at a temporary inspection URL first, such as one from webhook.site, to see the payload before writing code.

For Ship by Loop subscriptions, Loop's schema adds two requirements: the URL must be reachable over HTTPS, and it must respond to an HTTP HEAD request, which Loop sends when the subscription is created to verify the URL. Make sure whatever sits at that URL answers HEAD before you create the subscription.

A Hookdeck source answers HEAD with 405 by default. Add HEAD to the source's allowed HTTP methods before creating a Ship by Loop subscription; Hookdeck then returns 200, and the unsigned HEAD shows up as a rejected request you can ignore.

Securing Loop Returns webhooks

Compute an HMAC-SHA256 of the raw request body with the webhook's secret, encode the digest as standard base64 and compare it with X-Loop-Signature in constant time.

const crypto = require('crypto');
const express = require('express');

function verifyLoopSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('base64');
  const received = Buffer.from(signatureHeader.trim(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/loop', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.LOOP_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyLoopSignature(req.body, req.get('x-loop-signature'), secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Queue the event for processing, keyed on event.topic and event.trigger.
  res.sendStatus(202);
});

The same check in Python:

import base64
import hashlib
import hmac


def verify_loop_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    digest = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
    expected = base64.b64encode(digest)
    return hmac.compare_digest(signature_header.strip().encode("utf-8"), expected)


# In FastAPI: raw_body = await request.body()
# verify_loop_signature(raw_body, request.headers.get("x-loop-signature"),
#                       os.environ["LOOP_WEBHOOK_SECRET"])

As a test vector, the secret loop-guide-test-secret and the body {"topic":"return","trigger":"return.created","id":"12345"} give dkrePGfzHbcdrszsRw4KKQsCEazK2m+m42q6+ZafAYs=.

Always hash the bytes you received. Loop's Ship by Loop schema notes that the signed JSON has slashes escaped and newlines removed. A standard JSON library re-encoding a parsed body will not necessarily reproduce that, so verification after parsing can fail on valid requests.

Loop does not retry a 401. A forged request answered with 401 is dropped, which is what you want, but a genuine request rejected because of a wrong or rotated secret is also dropped. Monitor signature failures closely.

Make Loop Returns webhooks production-ready. Hookdeck Event Gateway verifies Loop's X-Loop-Signature header, answers Loop within its 10 second window, and holds each return event until your service has processed it.

Loop Returns webhook limitations and pain points

Many failure codes are never retried

The Problem: Returns events disappear after your service returns an error.

Why It Happens: Loop retries only network failures, 429, 500, 502, 503 and 504. A 400, 401, 403, 404, 405, 410 or 422 fails the webhook immediately, so a validation bug or a missing route loses the event.

Workarounds:

  • Return 2xx once the signature is valid, and handle business errors in your own queue.
  • Return 503 rather than 4xx when a dependency is down and you want Loop to try again.

How Hookdeck Can Help: Hookdeck accepts Loop's request and responds straight away, so errors from your service never reach Loop. Failed deliveries to your service are retried on your schedule and can be replayed after a fix.

A 10 second window and a short retry budget

The Problem: Slow handlers cause failures, and a long outage loses events.

Why It Happens: A response later than 10 seconds counts as a failure, and Loop makes six attempts in total. Loop says these values are set by config and subject to change.

Workarounds:

  • Verify, enqueue and respond, then process.
  • If you are rate limiting, send 429 with a retry-after of 30 minutes or less. Longer values fail the webhook.

How Hookdeck Can Help: Hookdeck stores each event when it arrives and can throttle delivery to your service, so bursts after a busy returns day do not trigger Loop's retry logic.

At-least-once delivery without an event ID

The Problem: The same return update is processed twice.

Why It Happens: Loop delivers at least once, and the request carries no delivery ID.

Workarounds:

  • Key idempotency on trigger plus the object's ID and a timestamp, such as the return's id and edited_at. See our guide to webhook idempotency.

How Hookdeck Can Help: Hookdeck's deduplication can drop repeats based on the body fields you choose.

Payloads change without notice

The Problem: A strict schema validator starts rejecting Loop's webhooks.

Why It Happens: Loop may add fields to any payload at any time. It does not change or remove existing fields.

Workarounds:

  • Allow unknown properties in your schema, and read only the fields you use.

How Hookdeck Can Help: Hookdeck doesn't validate the payload's fields, so a new field never causes a rejection before the event reaches your service.

Best practices

Verify against the raw body

Mount express.raw() on the webhook route and parse only after verification. In Next.js, use await request.text(). In FastAPI, read await request.body().

Match topics to triggers

Subscribe each trigger with the topic it was designed for: Return for return triggers, Label for label triggers, and so on. A mismatched pair produces empty or partial payloads.

Use status codes deliberately

Return 2xx for anything you have accepted, 503 or 429 when you want Loop to retry, and reserve 4xx for requests you never want to see again, such as an invalid signature.

Acknowledge fast and process asynchronously

Refunds, restocks and ERP updates all involve external calls. Keep them out of the request. See why to process webhooks asynchronously.

Conclusion

Loop Returns webhooks pair a topic with a trigger, carry a base64 HMAC-SHA256 of the raw body in X-Loop-Signature, and expect a 2xx within 10 seconds. Verify the raw bytes, choose topics that fit their triggers, return 2xx once you have the event, and use 5xx or 429 only when you want a retry, because Loop drops every other failure.

Hookdeck Event Gateway sits between Loop and your services, verifies each request, responds within Loop's window and keeps retrying towards your service until the event is processed.

Get started with Hookdeck for free and handle Loop Returns webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.