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 GitLabBefore you start
- A Loop API key with the
ReturnsandDeveloper Toolsscopes — 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
ServerSignature verifier
ServerEvent log
ServerWork queue
ServerRules engine
ServerSide-effect claims
ServerReconciliation poller
Serverupdated_at that catches anything the webhook path missed, tracked against a watermark. Turns "are my webhooks healthy?" into a number.Return projection
ServerWarehouse boundary
ServerThe flow
What happens, in order, end to end.
Subscribe on boot
The app registers its webhook subscriptions against its public URL at startup and removes them on shutdown. Worth noting:POST /webhooksdefaults toinactive, so passstatus: "active"if you want deliveries immediately.A customer submits a return
Loop firesreturn.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.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.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.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.Label events carry the shipping truth
return.createdusually 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.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.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.Reconcile on close
return.closedis 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.Meanwhile, the poller sweeps
On a schedule, independent of all of the above, a sweep overupdated_atlooks 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.
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.
// 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.
// 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, notBearer. The API key goes in that header directly. - Processing is asynchronous. A success from
processmeans queued — exchange orders and gift cards are created in the background. Treatreturn.closedas the completion signal rather than reading state back immediately. removealso 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,
toonly applies whenfromis 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.