# Guide to Loop Returns Webhooks: Features and Best Practices

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

| Feature | Details |
| --- | --- |
| Configuration | Developer Tools page of the Loop Admin |
| Subscription model | One topic plus one trigger per subscription |
| Signature header | `X-Loop-Signature` |
| Scheme | HMAC-SHA256, standard base64 |
| Signed content | The raw request body |
| Secret | One per webhook, shown on the Developer Tools page |
| Replay window | None. No timestamp is sent or signed |
| Response window | `200`, `202` or `204` within 10 seconds |
| Retries | Five (six attempts in total), exponential backoff with jitter |
| Retryable codes | `0` (network), `429`, `500`, `502`, `503`, `504` |
| `retry-after` | Honoured up to 30 minutes; longer values fail the webhook |
| Delivery guarantee | At least once |
| Payload changes | New fields can appear without notice, additive only |
| Source IPs | Not published |

## Common events

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

| Trigger | Topic | Fires when |
| --- | --- | --- |
| `return.created` | Return | A new return was submitted in the customer returns portal |
| `return.updated` | Return | A return was updated, including state changes such as open to closed, cancelled or expired |
| `return.closed` | Return | A return's state has been updated to closed |
| `return.processing.failed` | Return processing | Queued return processing failed to complete |
| `label.created` | Label | A shipping label has been created |
| `label.updated` | Label | A shipping label has been updated, for example from new to in transit |
| `restock.requested` | Restock | An item in the return has been restocked in Shopify |
| `giftcard.requested` | Gift card | A gift card has been requested as part of an exchange |
| `shipment.processed` | Happy Returns shipment | A Happy Returns shipment has been processed by Loop |

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

| Trigger | Topic | Fires when |
| --- | --- | --- |
| `label.request.issued` | Label request | Loop needs a label for this return |
| `label.request.cancelled` | Label request | A previously issued label request was cancelled |
| `ship-by-loop.services.requested` | Ship by Loop shipment | A shipment was created that this provider can fulfil |
| `ship-by-loop.purchase.requested` | Ship by Loop shipment | Services have been selected and are ready for purchase |
| `ship-by-loop.cancellation.requested` | Ship by Loop shipment | The 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:

```json
{
  "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.

```javascript
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:

```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](/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](/docs/retries) 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](/docs/destinations#set-a-max-delivery-rate) 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](/webhooks/guides/implement-webhook-idempotency).

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Loop Returns webhooks reliably in minutes.