# Guide to Loop Webhooks: Features and Best Practices

Loop webhooks tell your systems when something changes in your freight spend: a payment is initiated, completes or fails, a receivable invoice is created or finalized, a payable invoice review finishes, a shipment job is re-rated, or an audit exception is raised. If you run logistics payments and freight audit on Loop and keep your ERP, TMS or finance tooling in step with it, webhooks are how those systems hear about changes without polling Loop's API.

This guide is about Loop at loop.com, the logistics payments platform. Loop Returns and Loop Subscriptions are Shopify apps from different companies with their own webhooks. It covers how Loop endpoints are set up and signed, the events Loop sends, how to deduplicate and order them, and the best practices for running a Loop receiver in production.

## What are Loop webhooks?

Loop delivers webhooks through Svix. You add an endpoint URL in the Loop app and choose the event types it should receive, and Loop sends each matching event as a JSON `POST`. Every request carries the Svix headers `svix-id`, `svix-timestamp` and `svix-signature`.

The bodies are flat. Each one has `eventType`, `eventCreatedAt` and usually `eventId` at the top level, next to the event's own fields. Loop identifies objects with qualified IDs such as `qid::payment:0ca6f700-...`, so a payment event carries a `paymentQid` and an invoice event a `receivableInvoiceQid`.

Two details shape every Loop integration. Delivery is at least once, so duplicates happen. And Loop says to deduplicate on the body's `eventId`, not on the `svix-id` header, because `svix-id` "can change if Loop resends an event".

## Loop webhook features

| Feature | Details |
| --- | --- |
| Configuration | The Loop app: **Settings > Webhooks**, a Svix application portal |
| Event selection | A list of event types per endpoint. None selected means all events |
| Testing | A **Testing** tab sends example events to your endpoint |
| Signature headers | `svix-id`, `svix-timestamp`, `svix-signature` |
| Scheme | HMAC-SHA256, base64, over `{svix-id}.{svix-timestamp}.{raw body}` |
| Secret | One per endpoint, `whsec_` prefixed |
| Event name | Top-level `eventType` field |
| Deduplication key | `eventId` in the body, stable when Loop resends an event |
| Ordering | Not guaranteed. Use `eventCreatedAt` |
| Response window | A `2xx` within 15 seconds |
| Retries | Immediately, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours |
| Auto-disable | After 5 days of failures. Re-enable with **Enable Endpoint** |
| Recovery | Resend single messages, or **Recover Failed Messages** from a chosen time |
| Rate limiting | Optional per-endpoint rate limit under **Advanced** |

Loop asks you to disable CSRF protection on the webhook route if your framework turns it on by default. Loop publishes no source IP list of its own.

## Common events

Loop's event catalog lists 22 event types:

| Area | Events |
| --- | --- |
| Payments | `payment.initiated`, `payment.completed`, `payment.failed` |
| Receivable invoices | `receivable-invoice.created`, `receivable-invoice.finalized`, `receivable-invoice.voided` |
| Payable invoices | `payable-invoice-review.completed`, `payable-invoice.shipment-job-linked`, `payable-invoice.shipment-job-unlinked` |
| Shipment jobs | `shipment-job.normalized`, `shipment-job.rerated` |
| Shipment job artifacts | `shipment-job-artifact.assessed`, `shipment-job-artifact.unassessed`, `shipment-job-artifact.assigned`, `shipment-job-artifact.unassigned` |
| Business exceptions | `business-exception.created`, `business-exception.resolved`, `business-exception.reopened`, `business-exception.deleted` |
| Other | `artifact.root-ingestion-completed`, `quick-pay-relationship.revised`, `test-event.created` |

Payloads are small and point at objects rather than carrying them in full. Here is the catalog example for `business-exception.created`:

```json
{
  "eventCreatedAt": "2023-01-01T00:00:00.000Z",
  "eventId": "JG6_GkHkURAUFawN4JTV_4UPaTNvWbIU1KAcgsvuv1s",
  "eventType": "business-exception.created",
  "exceptionName": "Variance over threshold",
  "exceptionTypeQid": "qid::business_exception_type:6b6b0a26-2404-4add-8519-89fc5f173c54",
  "qid": "qid::business_exception:6b6b0a26-2404-4add-8519-89fc5f173c54",
  "targetEntityQid": "qid::payable_invoice:0ca6f700-4137-4d09-b44a-43bb8d7900c4",
  "targetEntityType": "PAYABLE_INVOICE"
}

```

A `payment.completed` event carries `paymentQid`, `paymentDisplayId`, `initiatedAt` and `completedAt`. A `receivable-invoice.created` event carries only `receivableInvoiceQid` beside the three event fields. To get the full object, call the Loop API with the ID.

One example conflicts with the rest. The `test-event.created` body printed on Loop's Webhooks page has `generatedAtTimestamp` and no `eventType`, while the catalog's schema for the same event requires `eventType` and uses `eventCreatedAt`. Send a test event from the **Testing** tab and check what your handler actually receives.

## Setting up Loop webhooks

1. In the Loop app, open **Settings** and click **Webhooks** under your organisation name.
2. Add an endpoint with your HTTPS URL and the event types you want. If you leave the event types empty, the endpoint receives every event. Loop suggests narrowing this later "to avoid receiving unexpected messages".
3. Open the endpoint and copy its signing secret. Store it server-side, for example as `LOOP_WEBHOOK_SECRET`. Secrets are unique to each endpoint.
4. Use the **Testing** tab to send example events, then open each message to see its payload and every delivery attempt.
5. Optionally, set a rate limit for the endpoint under **Advanced**. Svix spreads messages out to stay under it.

Loop recommends one endpoint per service that listens to all the event types it needs, rather than one endpoint per event.

## Securing Loop webhooks

Verify the signature before you parse the body. Build the signed content as `{svix-id}.{svix-timestamp}.{raw body}`, compute an HMAC-SHA256 with the base64-decoded part of your secret after `whsec_`, base64-encode it, and compare it in constant time with each `v1,` entry in `svix-signature`.

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

function verifyLoopSignature(rawBody, headers, secret) {
  const id = headers['svix-id'];
  const timestamp = headers['svix-timestamp'];
  const signatureHeader = headers['svix-signature'];
  if (!id || !timestamp || !signatureHeader) return false;
  if (typeof secret !== 'string' || !secret.startsWith('whsec_')) return false;

  // The key is the base64-decoded part of the secret after "whsec_".
  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  // The header can hold several space-separated "v1,<base64>" entries.
  return signatureHeader.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'base64');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

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.headers, secret)) {
    return res.sendStatus(401);
  }

  // Optional: if your own policy needs it, reject svix-timestamp values that are too old.
  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.eventId, route on event.eventType, then process async.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac

def verify_loop_signature(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("svix-id")
    timestamp = headers.get("svix-timestamp")
    signature_header = headers.get("svix-signature")
    if not (msg_id and timestamp and signature_header):
        return False
    if not isinstance(secret, str) or not secret.startswith("whsec_"):
        return False

    key = base64.b64decode(secret[len("whsec_"):])
    signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())

    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode(), expected):
            return True
    return False

```

Loop's docs show verification with the official `svix` library (`new Webhook(secret).verify(payload, headers)`), which does the same check and also rejects timestamps more than five minutes from the current time.

To check your verifier, use the secret `whsec_aG9va2RlY2stZ3VpZGUtdGVzdC1rZXktMzJieXRlcyE=`, the headers `svix-id: msg_2kLoopTest0001` and `svix-timestamp: 1761040800`, and this exact body:

```
{"eventType":"payment.completed","eventId":"JG6_GkHkURAUFawN4JTV_4UPaTNvWbIU1KAcgsvuv1s"}

```

The `svix-signature` header should be:

```
v1,f4e4CzMOlOmbPTn2QMxXdJyk9JVA4ebWUreNCAwKji4=

```

> **Make Loop webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the Svix signature on every Loop delivery, deduplicates on `eventId`, and durably queues payment and invoice events before your service sees them.

## Loop webhook limitations and pain points

### The delivery ID is not the event ID

**The Problem:** A handler that deduplicates on `svix-id` processes the same payment twice.

**Why It Happens:** `svix-id` identifies one delivery message. Loop says it "can change if Loop resends an event". The stable identifier is `eventId` in the body, which "stays the same" across resends. Loop also deduplicates identical `eventId`s on its side, but still tells you to deduplicate yourself.

**Workarounds:**

* Store each `eventId` you process and skip repeats.
* Where a body has no `eventId` (Loop says payloads "may" carry it), fall back to `eventType`, the object's `qid` and `eventCreatedAt`.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeated deliveries based on the fields you choose, such as the body's `eventId`.

### Events arrive out of order

**The Problem:** A `payment.failed` can arrive after a later `payment.completed` for the same payment, and your record ends up in the wrong state.

**Why It Happens:** Svix does not guarantee ordering, and retries make it worse because a failed message is retried while newer ones go through.

**Workarounds:**

* Keep the latest `eventCreatedAt` per entity and ignore anything older.
* When an event arrives, re-fetch the entity from the Loop API rather than applying the payload as truth. Loop calls this "the most fool-proof way".

**How Hookdeck Can Help:** Hookdeck stores every event with its timestamp and lets you inspect and replay any of them, which makes out-of-order sequences easy to find and reprocess.

### A five-day outage disables the endpoint

**The Problem:** After a long outage, Loop stops sending, and events pile up as failed messages until someone re-enables the endpoint.

**Why It Happens:** Svix retries each message for about 27 hours. If all attempts to an endpoint fail for 5 days, the endpoint is disabled.

**Workarounds:**

* Respond with a `2xx` within 15 seconds and do the work asynchronously.
* After an outage, click **Enable Endpoint**, then use **Recover Failed Messages** for the window you missed.
* Return real error codes. Loop treats any `2xx` as delivered, even if your body says the processing failed.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives, so Loop sees a healthy endpoint while your service is down. You then [retry](/docs/retries) and replay events from Hookdeck when you're ready.

### Example payloads disagree

**The Problem:** A handler built from the `test-event.created` example on Loop's Webhooks page does not match what the catalog says.

**Why It Happens:** The prose example has `generatedAtTimestamp` and no `eventType`. The catalog schema requires `eventType` and `eventCreatedAt`.

**Workarounds:**

* Build handlers from the event catalog, not the prose example.
* Send a test event from the **Testing** tab and log the exact body before you rely on it.

**How Hookdeck Can Help:** Every delivery to a Hookdeck source is stored with its full headers and body, so you can see exactly what Loop sent.

## Best practices

### Verify against the raw body

Loop says this is "the most common issue": signatures are computed over the raw string body, and re-serialising JSON produces different bytes. In Express, use `express.raw()` on the webhook route. In FastAPI, read `await request.body()`.

### Use the right secret for each endpoint

Each endpoint has its own secret. If you send staging and production to different endpoints, each needs its own stored secret. A secret from another endpoint produces a valid-looking signature that never matches.

### Subscribe to the events you handle

An endpoint with no event types receives everything, including event types Loop adds later. Select the events you process, and acknowledge anything unknown with a `2xx` so it doesn't count as a failure.

### Treat events as notifications

Loop's own advice is to use each event "as a signal to synchronize current state via the Loop API". The payloads are mostly IDs, so fetch the object, and you avoid acting on stale data from a delayed or reordered event.

### Acknowledge fast and process asynchronously

Return a `2xx` within 15 seconds and queue the work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Make handlers idempotent

Processing the same event twice should have the same result as processing it once. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Loop sends payment, invoice, shipment job and exception events through Svix, signed with an HMAC-SHA256 over `svix-id`, `svix-timestamp` and the raw body. Verify with the endpoint's own `whsec_` secret, deduplicate on `eventId` rather than `svix-id`, order on `eventCreatedAt`, and fetch the object from the Loop API before acting on it.

[Hookdeck Event Gateway](https://hookdeck.com) checks every `svix-signature`, drops duplicate events, and holds each one until your service has processed it, so a long outage on your side never ends with a disabled Loop endpoint.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Loop webhooks reliably in minutes.