Gareth Wilson Gareth Wilson

Guide to Loop Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationThe Loop app: Settings > Webhooks, a Svix application portal
Event selectionA list of event types per endpoint. None selected means all events
TestingA Testing tab sends example events to your endpoint
Signature headerssvix-id, svix-timestamp, svix-signature
SchemeHMAC-SHA256, base64, over {svix-id}.{svix-timestamp}.{raw body}
SecretOne per endpoint, whsec_ prefixed
Event nameTop-level eventType field
Deduplication keyeventId in the body, stable when Loop resends an event
OrderingNot guaranteed. Use eventCreatedAt
Response windowA 2xx within 15 seconds
RetriesImmediately, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours
Auto-disableAfter 5 days of failures. Re-enable with Enable Endpoint
RecoveryResend single messages, or Recover Failed Messages from a chosen time
Rate limitingOptional 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:

AreaEvents
Paymentspayment.initiated, payment.completed, payment.failed
Receivable invoicesreceivable-invoice.created, receivable-invoice.finalized, receivable-invoice.voided
Payable invoicespayable-invoice-review.completed, payable-invoice.shipment-job-linked, payable-invoice.shipment-job-unlinked
Shipment jobsshipment-job.normalized, shipment-job.rerated
Shipment job artifactsshipment-job-artifact.assessed, shipment-job-artifact.unassessed, shipment-job-artifact.assigned, shipment-job-artifact.unassigned
Business exceptionsbusiness-exception.created, business-exception.resolved, business-exception.reopened, business-exception.deleted
Otherartifact.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:

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

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:

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 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 eventIds 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 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 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.

Make handlers idempotent

Processing the same event twice should have the same result as processing it once. See our guide to 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 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 for free and handle Loop 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.