LoopSolutions
All examples
WebhooksReturn ActionsTypeScriptadvanced

Two-Way Integration

Most integrations only listen. A two-way one also decides: it grades returned items, records where they went, processes refunds, and flags what a human should look at. This is a working reference for that pattern — the receiving side (signature verification, at-least-once delivery, a 10-second budget), the safety net (a reconciliation poller for anything webhooks drop), and the writing side (every return action Loop exposes). Most of the value is in the design decisions rather than the API calls, so that's what's covered below. The linked TypeScript repo runs end to end against your own sandbox, with a warehouse simulator so you can drive a return from submission to refund by yourself.

View full code on GitLab

Before you start

  • A Loop API key with the Returns and Developer Tools scopes — Loop Admin → Settings → Developer tools.
  • The webhook signing secret from that same page. It's a shop-level value that isn't exposed through the API, so it belongs in your secret store rather than being fetched at boot.
  • An HTTPS tunnel so Loop can reach your local server — ngrok or similar.
  • Optional: add Create Returns (read) and Create Returns (write) if you want to automate return testing. Those cover the Draft Returns API, which the bundled test-data helper uses to create returns from the command line instead of clicking through the portal for every run. Nothing in the integration itself needs them.
  • Node 20.11+.

The pieces you need

Language-agnostic — every implementation has these moving parts.

Webhook endpoint

Server
Verifies the signature, writes the event to a log, and responds — nothing else. Everything slower happens after the response goes out.

Signature verifier

Server
Computes an HMAC over the raw request bytes and compares it in constant time. Needs access to the unparsed body, which most frameworks discard unless you ask for it.

Event log

Server
A durable record of every event with a unique dedupe key, so a redelivery is recognized and skipped. It's also the handoff between the endpoint and the worker.

Work queue

Server
Drains the event log after the HTTP response. In production this should be a real queue so work survives a deploy — the durable part is the log, not the scheduling.

Rules engine

Server
Where your system decides what an event means: mint an RMA, flag a high-value return, reconcile a closed one. Every write is guarded so it happens at most once.

Side-effect claims

Server
A durable record keyed on what the write is, not on the event that triggered it. This is what stops the same fact arriving from two sources and being acted on twice.

Reconciliation poller

Server
A scheduled sweep over updated_at that catches anything the webhook path missed, tracked against a watermark. Turns "are my webhooks healthy?" into a number.

Return projection

Server
What your system believes about each return, and how it first heard about it — so the poller can tell "new to me" from "already seen".

Warehouse boundary

Server
Where physical inspection enters the system: grades, dispositions, and the process-or-flag decision. In a real deployment this is your WMS or 3PL calling in.

The flow

What happens, in order, end to end.

  1. Subscribe on boot

    The app registers its webhook subscriptions against its public URL at startup and removes them on shutdown. Worth noting: POST /webhooks defaults to inactive, so pass status: "active" if you want deliveries immediately.
  2. A customer submits a return

    Loop fires return.created. The payload arrives signed, and Loop expects a response within 10 seconds — after that the delivery counts as failed and enters a retry cycle.
  3. Verify, log, acknowledge

    The endpoint checks the HMAC against the raw bytes, writes the event to the log under a dedupe key, and responds 200. A duplicate is acknowledged and dropped right here. Nothing else happens on this thread.
  4. The queue picks it up

    After the response is sent, a worker pulls the event and hands it to the rules engine. This is where API calls back into Loop and writes to your own systems happen, with no clock running.
  5. Your rules decide

    Mint an RMA and note it on the return's timeline; flag anything above a value threshold before it can auto-process. Each write is claimed once, so it holds whether the event came by webhook, by poller, or both.
  6. Label events carry the shipping truth

    return.created usually fires before a label exists, so its tracking fields come through as "N/A". Subscribe to the label topic separately — that's where tracking numbers actually become reliable.
  7. The parcel arrives and gets inspected

    The warehouse grades each item's condition and records a disposition — back to stock, resale hold, recycle, donate, or missing. Both take a line item ID rather than a return ID, and both cap at 30 items per call.
  8. Process, or escalate

    Everything received and resellable gets processed. Damaged items, an empty parcel, or a value over the threshold get flagged for a human instead. Grading is recorded before processing — processing fulfils the outcomes, so inspection data arriving after is a report, not a decision.
  9. Reconcile on close

    return.closed is the completion signal: by the time it fires, the refund, exchange order, or gift card actually exists, so your ledger can be closed against real numbers.
  10. Meanwhile, the poller sweeps

    On a schedule, independent of all of the above, a sweep over updated_at looks for returns the webhook path never delivered. Anything it finds is counted, logged, and fed through the same pipeline.

Acknowledge first, work later

Loop treats a webhook as failed if you don't respond within 10 seconds, then retries with exponential backoff. Any handler that grades items or calls an ERP inline will eventually blow that budget, get retried, and start double-processing its own work.

So the endpoint does the least possible: verify, persist, respond. The rules run afterwards, off a queue, with the event log as the durable handoff. This one decision is what keeps the integration correct once it's handling real volume — and it's much harder to retrofit than to start with.

Hash the raw bytes, not the parsed object

The signature is an HMAC of the exact bytes Loop sent. JSON.parse followed by JSON.stringify is not byte-identical — key order and whitespace shift — so the HMAC will never match. This is the single most common reason signature verification fails on a first integration.

Most frameworks throw the raw body away once they've parsed it, so you have to capture it during parsing. Compare in constant time too: a plain === on an HMAC leaks how much of the digest matched.

verify.ts (constant-time compare)ts
const expected = createHmac('sha256', secret).update(rawBody).digest('base64');

const expectedBuffer = Buffer.from(expected, 'utf8');
const receivedBuffer = Buffer.from(signatureHeader, 'utf8');

// timingSafeEqual throws on a length mismatch, so check length first — a
// different length is already a definitive mismatch.
if (expectedBuffer.length !== receivedBuffer.length) {
  return { status: 'failed', reason: 'Signature length mismatch' };
}

if (!timingSafeEqual(expectedBuffer, receivedBuffer)) {
  return { status: 'failed', reason: 'Signature did not match the computed HMAC' };
}

Deduplicate side effects, not just events

Loop delivers at least once — the right trade, since you're never silently missing an event as long as your handler is idempotent. Hashing the payload gives you a dedupe key that catches redelivery.

But that isn't enough once you also poll. A poller sweep is a second, independent observation of the same underlying fact, and it carries a different dedupe key. Both reach the rules engine legitimately. Without another guard, both write a note and your ledger reconciles the same refund twice — the kind of bug you find at month end.

The fix is a durable claim per side effect, keyed on what the write is rather than on the event that triggered it. Release the claim if the action throws, so a transient API failure doesn't permanently suppress the retry.

engine.ts (claim a side effect once)ts
// Keyed on the write itself, so it holds no matter which source got here first.
await once(returnId, 'reconcile-closed', async () => {
  await createReturnNote(returnId, `Reconciled · refund ${refund}`);
});

Reconcile on updated_at, with an overlap

Webhooks are the fast path and they're reliable, but they're not a guarantee — your endpoint can be down for a deploy, a delivery can exhaust its retries, and a 4xx isn't retried at all. So run both: webhooks for latency, a periodic sweep for completeness.

Three things make the sweep actually work. Filter on updated_at, not created_at — a return created last Tuesday and processed this morning has an old creation date, and sweeping on it would never see the change that matters. Keep a watermark so a restart resumes instead of re-reading a week. And rewind that watermark by an overlap window on every sweep: clocks drift, and a return can become visible in the list endpoint slightly after its own timestamp. A zero-overlap watermark silently drops rows.

The payoff is a number you can watch. Anything the sweep sees that no webhook ever delivered gets counted — if that isn't zero in production, your endpoint is dropping deliveries.

Sweep every state explicitly

GET /warehouse/return/list filters by state, defaults to open / closed / expired, and takes one state per request.

That default matters more than it looks. Flagged returns sit in review — the state POST /flag moves them into — so a default sweep covers returns moving through the normal flow but misses the ones waiting on a human decision. Those are usually the ones you most want mirrored in your own system.

Enumerate the states you care about instead of relying on the default. Each one costs a paginated request per sweep.

reconcile.ts (states, spelled out)ts
// One request per state. 'review' is the one a default sweep would miss.
const SWEEP_STATES = ['open', 'review', 'closed', 'cancelled', 'expired'];

Automate the confident cases, escalate the rest

The rules here flag rather than guess whenever they aren't sure: value over a threshold, an item graded unsellable, a parcel that arrived empty. An integration that processes everything is worse than no integration, because it removes the human who would have caught the exception. The value is in choosing which cases are genuinely safe.

Every decision also lands on the return's timeline as a note. When support asks why a return was processed at 3am, the answer is already there — and since cancel and flag take only a return ID, a note is where the why has to live.

Things worth knowing

  • Auth is X-Authorization, not Bearer. The API key goes in that header directly.
  • Processing is asynchronous. A success from process means queued — exchange orders and gift cards are created in the background. Treat return.closed as the completion signal rather than reading state back immediately.
  • remove also processes. Removing line items takes them off the return and processes it in the same call, so batch every removal into one request. Refund and store-credit lines only.
  • Return notes use content, capped at 255 characters. Split longer content across numbered notes instead of letting it truncate.
  • Identifier types vary. IDs arrive as strings on some payloads and numbers on others, and the grading endpoints require integers. Normalize once at the boundary.
  • The returns list has sharp edges. It defaults to the last 24 hours, to only applies when from is also given, and ranges cap at 120 days. Pass both explicitly.
  • Payloads are additive. New fields may show up without existing ones changing meaning, so avoid strict validation that rejects unknown properties.
  • Branch on the error code, not the message. Error bodies vary in shape by endpoint, and the prose changes.

Run it yourself

Clone the repo, add your API key and signing secret, point it at an ngrok tunnel, and npm run dev. It registers its own subscriptions on boot and cleans them up on shutdown, so a fresh tunnel URL each session needs no manual tidying.

A local ops console shows events landing in real time and which source each arrived from, with a button for every action. The bundled warehouse simulator means you can drive a return from submission through grading to refund solo — no WMS or 3PL attached — including the awkward cases like a parcel that showed up short an item.