# Loop Webhooks

Loop (loop.com) is a freight audit and logistics payments platform. It sends
webhooks for payments, payable and receivable invoices, shipment jobs, document
artifacts and business exceptions. Delivery runs on **Svix**, so every webhook
carries `svix-id`, `svix-timestamp` and `svix-signature` headers.

> **Not Loop Returns or Loop Subscriptions.** Those are Shopify apps from
> different companies with their own webhook schemes. This skill covers
> `docs.loop.com` only.

## When to Use This Skill

* How do I receive Loop webhooks?
* How do I verify a Loop webhook signature?
* Why is my Loop `svix-signature` verification failing?
* How do I handle `payment.completed` or `receivable-invoice.finalized` events?
* How do I deduplicate Loop webhook deliveries?
* How do I send a Loop test event (`test-event.created`)?

## Signature Verification

|  |  |
| --- | --- |
| Headers | `svix-id`, `svix-timestamp` (Unix seconds), `svix-signature` |
| Algorithm | HMAC-SHA256, **base64**-encoded |
| Signed content | `{svix-id}.{svix-timestamp}.{raw body}` |
| Key | Signing secret minus the `whsec_` prefix, **base64-decoded** |
| Header format | Space-separated `v1,<sig>` entries; accept if any matches |
| Tolerance | Svix SDKs reject timestamps more than 5 minutes off |

Loop's docs recommend the official Svix libraries (`npm i svix`, `pip install svix`).

## Verification (core)

```javascript
import { Webhook } from 'svix'; // svix 2.x is ESM-only

// rawBody: the exact string/Buffer Loop sent, NOT JSON.stringify(req.body)
new Webhook(process.env.LOOP_WEBHOOK_SECRET).verify(rawBody, {
  'svix-id': headers['svix-id'],
  'svix-timestamp': headers['svix-timestamp'],
  'svix-signature': headers['svix-signature'],
}); // throws on a bad signature or stale timestamp -> return 400

```

What the SDK computes, if you need to do it by hand:

```javascript
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = crypto.createHmac('sha256', key)
  .update(`${svixId}.${svixTimestamp}.${rawBody}`).digest('base64');
const ok = svixSignature.split(' ').some((entry) => {
  const [version, sig] = entry.split(',');
  const a = Buffer.from(sig ?? ''), b = Buffer.from(expected);
  return version === 'v1' && a.length === b.length && crypto.timingSafeEqual(a, b);
}); // also reject if |now - svixTimestamp| > 300 seconds

```

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/loop-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/loop-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/loop-webhooks/examples/fastapi/).

## Payload and Deduplication

Payloads are **flat**: no `type`/`data` envelope. `eventType`, `eventId` and
`eventCreatedAt` sit at the top level beside the entity QIDs:

```json
{
  "eventCreatedAt": "2023-01-01T00:00:00.000Z",
  "eventId": "JG6_GkHkURAUFawN4JTV_4UPaTNvWbIU1KAcgsvuv1s",
  "eventType": "payment.completed",
  "initiatedAt": "2023-01-01T00:00:00.000Z",
  "completedAt": "2023-01-01T00:00:00.000Z",
  "paymentDisplayId": "LP12345678",
  "paymentQid": "qid::payment:0ca6f700-4137-4d09-b44a-43bb8d7900c4"
}

```

* **Deduplicate on the payload `eventId`, not `svix-id`.** Loop says `eventId`
  is stable across republishes, while `svix-id` identifies one delivery message
  and can change if Loop resends an event. Delivery is at least once.
* **Order is not guaranteed.** Track the latest `eventCreatedAt` per entity, or
  simply refetch the entity from the Loop API. Loop's guidance is to treat the
  payload as a notification, not a source of truth.
* Respond `2xx` within **15 seconds**; queue slow work.

## Event Types

| Group | 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.assigned`, `shipment-job-artifact.unassigned`, `shipment-job-artifact.assessed`, `shipment-job-artifact.unassessed` |
| Artifacts | `artifact.root-ingestion-completed` |
| Business exceptions | `business-exception.created`, `business-exception.resolved`, `business-exception.reopened`, `business-exception.deleted` |
| QuickPay | `quick-pay-relationship.revised` |
| Testing | `test-event.created` |

Note the hyphens inside resource names (`receivable-invoice`, `shipment-job-artifact`).
See [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/loop-webhooks/references/overview.md) for each event's fields.

## Environment Variables

```bash
# Endpoint signing secret from Loop's webhook portal ("whsec_" + base64)
LOOP_WEBHOOK_SECRET=whsec_your_signing_secret_here

```

## Local Development

```bash
npx hookdeck-cli listen 3000 loop --path /webhooks/loop

```

No account required. The CLI creates a guest account on first run and gives you
a public URL plus a web UI for inspecting requests. Add that URL as an endpoint
in Loop, then send `test-event.created` from the endpoint's **Testing** tab.

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/loop-webhooks/references/overview.md) - Every event type with payload fields, dedupe and ordering, retries
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/loop-webhooks/references/setup.md) - Adding an endpoint, getting the signing secret, testing, rate limits, recovery
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/loop-webhooks/references/verification.md) - Signature algorithm in detail, SDK and manual code, debugging failures