ReturnsIQ API Reference
The ReturnsIQ API lets you (or your developer) read and manage your store's returns data programmatically — build custom dashboards, sync returns into your ERP or helpdesk, automate approvals, and pull fraud signals into your own risk tooling. Everything you can do in the app, you can increasingly do by API.
New to APIs? You don't have to use this — the app does everything from its dashboard. The API is for merchants who want to build custom integrations. If you have a developer, hand them this page; it's all they need.
Base URL
https://returnsiq.kernvellabs.com/api/public/v1
All endpoints are under /api/public/v1. All traffic is HTTPS only.
Getting an API key
- In your Shopify admin, open ReturnsIQ → Settings → API Keys.
- Click Create key, name it (e.g. "Helpdesk sync"), pick the scopes (permissions) it needs, and optionally an expiry.
- Copy the key immediately — it's shown only once. Store it somewhere safe (a password manager or your server's secrets). If you lose it, revoke it and make a new one.
Keys look like kv_ret_live_xxxxxxxx (live) or kv_ret_test_xxxxxxxx (test mode).
Authentication
Send your key as a Bearer token on every request:
curl https://returnsiq.kernvellabs.com/api/public/v1/returns \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
(X-API-Key: kv_ret_live_xxxxxxxx works too.) Your key is tied to your store — you never pass a shop name. Unauthenticated requests get 401.
Test mode
Use a *_test_* key to build and experiment safely: test mode returns realistic previews for return creation, approve/deny actions, refunds, and policy creation without changing real returns, policies, refunds, Shopify orders, carrier labels, or customer emails. Test keys are available on every plan, so you can build before you upgrade. Test responses include the header X-Kernvel-Test-Mode: true.
Permissions (scopes)
Each key only does what you allow. Scopes are resource:action, e.g. returns:read, returns:write. A request missing the needed scope returns 403 insufficient_scope telling you which scope to add. Grant the least your integration needs. Customer personal data (name, email, and Shopify customer ID) sits behind its own customers:read scope and is only available while the store's current plan grants that scope.
The scopes available on ReturnsIQ:
| Scope | Grants |
|---|---|
returns:read |
Read returns and their line items |
returns:write |
Create returns and approve/deny them |
returns:manage |
Cancel returns and sensitive return operations |
refunds:manage |
Read refunds and issue refunds |
return_reasons:read |
Read the store's return-reason catalog |
policies:read |
Read return policies |
policies:write |
Create and update return policies |
fraud_signals:read |
Read fraud scores/signals on returns |
customers:read |
PII — read customer name, email, and Shopify customer ID on a return |
webhooks:manage |
Create and manage webhook subscriptions |
account:read |
Read your plan, usage, and API limits |
Plans
API access is part of your ReturnsIQ plan. In general: Free = test keys only; Starter = read access; Pro/Scale/Enterprise = full read/write/manage + webhooks + customer data. If an endpoint isn't in your plan you'll get 403 feature_not_in_plan with an upgrade link.
Requests
- Send JSON bodies with
Content-Type: application/json. - Pagination: list endpoints return up to 25 items by default (max 100). Use
?limit=and thenext_cursorfrom the response to page:curl "https://returnsiq.kernvellabs.com/api/public/v1/returns?limit=50&cursor=c2Vfb..." \ -H "Authorization: Bearer kv_ret_live_xxxxxxxx" - Filtering/sorting: each list endpoint documents its filters (e.g.
?status=requested&sort=requested_at&order=desc). - Idempotency (important for writes): send an
Idempotency-Key: <unique-id>header on anyPOST/PATCH/DELETE. If a request is retried (network hiccup), we return the original result instead of doing it twice — so a retried refund never happens twice. Required on money/state actions (approve, deny, issue refund, create return).
Responses
- A single item is returned directly; lists are wrapped:
{ "object": "list", "data": [ { "id": "ret_8fk3", "object": "return", "...": "..." } ], "has_more": true, "next_cursor": "c2Vfb..." } - Money is a string with a currency (
"refund_amount": "120.00", "currency": "USD"). Times are UTC ISO-8601 (2026-06-24T10:00:00Z). IDs are stable strings with a type prefix (ret_,rti_,rfd_,rsn_,pol_,frd_). - Every response includes
X-Request-Id(quote it in support tickets) and rate-limit headers (below).
Rate limits
Each key has a per-minute limit that scales with your plan. Every response includes:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 1750671600
If you exceed it you get 429 with a Retry-After header (seconds to wait). Write and heavy endpoints (refunds, fraud) have their own lower limits, noted on each.
Errors
Errors use a standard machine-readable format (application/problem+json):
{ "type": "https://docs.kernvellabs.com/api/errors/insufficient_scope",
"title": "Insufficient scope", "status": 403, "code": "insufficient_scope",
"detail": "This key is missing the required scope 'returns:write'.",
"errors": [ { "field": "scope", "message": "Required: returns:write" } ],
"request_id": "req_8fk3a1" }
Common codes: invalid_request, validation_failed, invalid_api_key, expired_api_key, insufficient_scope, feature_not_in_plan, not_found, idempotency_conflict, conflict, rate_limited, internal_error. The detail, errors, and request_id fields tell you what to fix and what to quote if you need support.
Webhooks (get notified instead of polling)
Rather than polling, subscribe to events and we'll POST them to your URL the moment they happen — a return is requested, approved, denied, received, or refunded, or a return crosses into the high fraud-risk band.
- Create a subscription (scope
webhooks:manage):
The response includes a signing secret (shown once).curl https://returnsiq.kernvellabs.com/api/public/v1/webhooks -X POST \ -H "Authorization: Bearer kv_ret_live_xxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "url": "https://yourapp.com/hooks/returnsiq", "events": ["return.requested"] }' - We POST an event envelope:
{ "id": "evt_8fk3", "type": "return.requested", "api_version": "2026-06-24", "created_at": "2026-06-24T10:00:00Z", "data": { "...": "the resource" } } - Verify every webhook using the
X-Kernvel-Signature: t=<unix>,v1=<hmac>header — computeHMAC-SHA256(secret, "<t>.<raw-body>")and compare; reject if the timestamp is older than 5 minutes.
We retry failed deliveries with backoff, record delivery attempts, and show failure counts on the subscription; persistent failures auto-disable the endpoint and email you. See Webhook events for the full catalog.
Versioning
The API is versioned in the URL (/api/public/v1). We won't make breaking changes to v1 — new fields may be added, but nothing is removed without a new version and 90+ days notice. The X-Kernvel-Api-Version response header shows the dated version applied.
Tools
- OpenAPI spec:
https://returnsiq.kernvellabs.com/api/public/v1/openapi.json(import into your tooling). - Postman collection: download the ReturnsIQ collection, or export the latest one from the API Keys page.
- Quickstart, errors, and webhooks: this reference includes key setup, request format, error shape, signature verification, and event catalog in one page.
Need help?
Email support@kernvellabs.com with the request_id from the response — that lets us find your exact call instantly.
Resources
The ReturnsIQ API exposes the objects that make up a return: the returns themselves, their return items, the refunds you issue against them, the return reasons catalog, your policies, and the AI fraud signals. Plus account (your plan and usage) and webhooks.
Every return flows through a state machine: requested → photo_required → under_review → approved → label_sent → in_transit → received → restocked → refunded, with rejected and cancelled as terminal off-ramps. The API enforces it — you can't refund a return that was never approved, and trying returns 409 conflict.
Returns
A return (object return, id prefix ret_) is the central record — one customer's request to send back one or more items from a Shopify order. It carries the RMA code, the order reference, status, money totals, the AI fraud score and decision, and the lifecycle timestamps.
GET /returns
List returns for your store, newest first. Cursor-paginated.
Scope: returns:read
| Param | Type | Description |
|---|---|---|
limit |
integer | 1–100, default 25 |
cursor |
string | next_cursor from a previous page |
status |
string | Filter by state: requested, photo_required, under_review, approved, label_sent, in_transit, received, restocked, refunded, rejected, cancelled, or the convenience value open (= requested + photo_required + under_review) |
Returns are always ordered newest-first (by id descending); there are no sort/order parameters.
curl "https://returnsiq.kernvellabs.com/api/public/v1/returns?status=under_review&limit=20" \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
{
"object": "list",
"data": [
{
"object": "return",
"id": "ret_8fk3",
"rma_code": "RMA-7G2K9",
"status": "under_review",
"outcome": null,
"customer_preferred_outcome": "refund",
"reason": "wrong_size",
"currency": "USD",
"total_value": "129.00",
"refund_amount": null,
"restocking_fee": null,
"fraud_risk_band": "low",
"shopify_order_id": 5012345678901,
"shopify_order_number": "#1042",
"item_count": 1,
"requested_at": "2026-06-24T09:14:00Z",
"decided_at": null,
"refunded_at": null,
"created_at": "2026-06-24T09:14:00Z"
}
],
"has_more": true,
"next_cursor": "c2Vfb3Vmazlk"
}
Notable errors: 400 invalid_request (unknown status filter or malformed cursor), 403 insufficient_scope (key lacks returns:read).
GET /returns/{id}
Fetch a single return. (Read its line items with GET /returns/{id}/items.)
Scope: returns:read (add customers:read to receive the customer PII fields — customer_email, customer_name, shopify_customer_id; without an effective current-plan grant those fields are null)
curl https://returnsiq.kernvellabs.com/api/public/v1/returns/ret_8fk3 \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
{
"object": "return",
"id": "ret_8fk3",
"rma_code": "RMA-7G2K9",
"status": "approved",
"outcome": "refund",
"customer_preferred_outcome": "refund",
"reason": "wrong_size",
"currency": "USD",
"total_value": "129.00",
"refund_amount": "116.10",
"restocking_fee": "12.90",
"fraud_risk_band": "low",
"shopify_order_id": 5012345678901,
"shopify_order_number": "#1042",
"item_count": 1,
"requested_at": "2026-06-24T09:14:00Z",
"decided_at": "2026-06-24T11:02:00Z",
"refunded_at": null,
"created_at": "2026-06-24T09:14:00Z",
"customer_email": "jordan@example.com",
"customer_name": "Jordan Lee",
"shopify_customer_id": 6789012345
}
The customer_email, customer_name, and shopify_customer_id fields appear only when the key holds customers:read and the store's current plan still grants it; otherwise they are null.
Notable errors: 404 not_found (no such return, or it belongs to another store — we return 404 rather than 403 so we never reveal another store's data).
POST /returns
Create a return on a customer's behalf — e.g. from your own portal or helpdesk. Idempotent.
Scope: returns:write · Send Idempotency-Key (required — this is a state-moving write)
| Field | Type | Required | Description |
|---|---|---|---|
order_number |
string | yes | The Shopify order number the items came from (e.g. "1042") |
email |
string | yes | Email on the order (used to match the order + notify) |
items |
array | yes | Line items to return, at least one (see below) |
reason |
string | no | A reason slug from GET /return_reasons |
customer_preferred_outcome |
string | no | refund, store_credit, exchange, or repair (advisory) |
customer_notes |
string | no | Extra context to show staff |
Each items[] entry takes shopify_line_item_id (integer, required) plus optional quantity (default 1), unit_value, condition, reason, sku, product_title, variant_title, shopify_product_id, and shopify_variant_id. Unknown fields are rejected.
curl https://returnsiq.kernvellabs.com/api/public/v1/returns -X POST \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1a3c9e-2b4d-4f8a-9c1e-7a2b3c4d5e6f" \
-d '{
"order_number": "1042",
"email": "jordan@example.com",
"customer_preferred_outcome": "refund",
"reason": "wrong_size",
"items": [
{ "shopify_line_item_id": 13987654321, "quantity": 1, "condition": "new", "reason": "wrong_size" }
]
}'
Returns the created return at 201 with status requested (the AI fraud score and decision are computed and attached within seconds; poll the return or subscribe to return.requested):
{
"object": "return",
"id": "ret_9aa2",
"rma_code": "RMA-3P8Q1",
"status": "requested",
"outcome": null,
"customer_preferred_outcome": "refund",
"reason": "wrong_size",
"currency": "USD",
"total_value": "129.00",
"refund_amount": null,
"restocking_fee": null,
"fraud_risk_band": null,
"shopify_order_id": 5012345678901,
"shopify_order_number": "#1042",
"item_count": 1,
"requested_at": "2026-06-24T12:00:00Z",
"decided_at": null,
"refunded_at": null,
"created_at": "2026-06-24T12:00:00Z"
}
Notable errors: 404 not_found (no order matches that order number + email), 422 unprocessable (a line item isn't on the order, or the order is otherwise unprocessable), 409 idempotency_conflict (same Idempotency-Key reused with a different body) / 409 conflict (billing-limit reached).
POST /returns/{id}/approve
Approve a return. Idempotent. The transition must be legal for the return's current state (see the state machine above) or you get 409 conflict. Emits return.approved.
Scope: returns:write · Send Idempotency-Key
No request body is required.
curl https://returnsiq.kernvellabs.com/api/public/v1/returns/ret_9aa2/approve -X POST \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx" \
-H "Idempotency-Key: 1c2d3e4f-5a6b-7c8d-9e0f-1a2b3c4d5e6f"
Returns the updated return at 200:
{
"object": "return",
"id": "ret_9aa2",
"rma_code": "RMA-3P8Q1",
"status": "approved",
"outcome": "refund",
"customer_preferred_outcome": "refund",
"reason": "wrong_size",
"currency": "USD",
"total_value": "129.00",
"refund_amount": "116.10",
"restocking_fee": "12.90",
"fraud_risk_band": "low",
"shopify_order_id": 5012345678901,
"shopify_order_number": "#1042",
"item_count": 1,
"requested_at": "2026-06-24T12:00:00Z",
"decided_at": "2026-06-24T12:05:00Z",
"refunded_at": null,
"created_at": "2026-06-24T12:00:00Z"
}
Notable errors: 409 conflict (illegal transition, e.g. approving an already-refunded return), 404 not_found.
POST /returns/{id}/deny
Deny (reject) a return. Idempotent. Emits return.denied. The transition must be legal for the return's current state or you get 409 conflict.
Scope: returns:write · Send Idempotency-Key
| Field | Type | Required | Description |
|---|---|---|---|
reason |
string | no | Why the return was denied (recorded as a note; max 2000 chars) |
curl https://returnsiq.kernvellabs.com/api/public/v1/returns/ret_9aa2/deny -X POST \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8d9e0f1a-2b3c-4d5e-6f7a-8b9c0d1e2f3a" \
-d '{ "reason": "Outside the 30-day return window." }'
Returns the updated return at 200 with status rejected.
Notable errors: 409 conflict (illegal transition), 404 not_found.
JavaScript (fetch) — list returns awaiting review
const BASE = "https://returnsiq.kernvellabs.com/api/public/v1";
const KEY = process.env.RETURNSIQ_API_KEY; // kv_ret_live_...
const res = await fetch(`${BASE}/returns?status=under_review&limit=50`, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (!res.ok) throw new Error(`ReturnsIQ ${res.status}: ${await res.text()}`);
const { data } = await res.json();
for (const r of data) {
console.log(`${r.rma_code} — ${r.fraud_risk_band || "unscored"} risk — $${r.total_value}`);
}
Return Items
A return item (object return_item, id prefix rti_) is one line of a return — a specific product/variant, its quantity, value, customer-reported condition, and the per-item outcome (part of a return can be approved while another part is denied). Items are returned inline on GET /returns/{id}; this endpoint lets you read them directly.
GET /returns/{id}/items
List the line items on a return.
Scope: returns:read
curl https://returnsiq.kernvellabs.com/api/public/v1/returns/ret_8fk3/items \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
{
"object": "list",
"data": [
{
"object": "return_item",
"id": "rti_2210",
"return_id": "ret_8fk3",
"shopify_line_item_id": 13987654321,
"shopify_product_id": 7654321098765,
"shopify_variant_id": 41234567890123,
"product_title": "Merino Crew Sweater",
"variant_title": "Navy / M",
"sku": "MCS-NVY-M",
"quantity": 1,
"unit_value": "129.00",
"total_value": "129.00",
"condition": "new",
"reason": "wrong_size",
"outcome": "refund",
"restocking_eligible": true,
"created_at": "2026-06-24T09:14:00Z"
}
],
"has_more": false,
"next_cursor": null
}
Notable errors: 404 not_found (no such return for this store), 403 insufficient_scope.
GET /return_items/{id}
Fetch a single return line item directly by its id.
Scope: returns:read
curl https://returnsiq.kernvellabs.com/api/public/v1/return_items/rti_2210 \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
Returns a single return_item object (same shape as the list entries above).
Notable errors: 404 not_found (no such item, or its parent return belongs to another store), 403 insufficient_scope.
JavaScript (fetch) — total the value of a return's items
const BASE = "https://returnsiq.kernvellabs.com/api/public/v1";
const KEY = process.env.RETURNSIQ_API_KEY;
const res = await fetch(`${BASE}/returns/ret_8fk3/items`, {
headers: { Authorization: `Bearer ${KEY}` },
});
const { data } = await res.json();
const total = data.reduce((sum, it) => sum + Number(it.total_value), 0);
console.log(`Returning ${data.length} line(s), total $${total.toFixed(2)}`);
Refunds
A refund (object refund, id prefix rfd_) is the money movement against a return — a cash refund to the original payment method (its method also records store-credit, exchange, or repair outcomes recorded in the app). Issuing a refund is the most sensitive write in the API, so it lives behind its own refunds:manage scope and requires an idempotency key — a retried request never refunds twice. The same refunds:manage scope also grants read access to refunds.
A refund's status is one of succeeded, failed, or pending; decided_by records who issued it (e.g. manual, ai_auto, or public_api).
GET /refunds
List every refund for your store, newest first. Cursor-paginated.
Scope: refunds:manage
| Param | Type | Description |
|---|---|---|
limit |
integer | 1–100, default 25 |
cursor |
string | next_cursor from a previous page |
return_id |
string | Filter to one return (ret_...) |
curl "https://returnsiq.kernvellabs.com/api/public/v1/refunds?limit=50" \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
{
"object": "list",
"data": [
{
"object": "refund",
"id": "rfd_5521",
"return_id": "ret_8fk3",
"method": "refund",
"amount": "116.10",
"currency": "USD",
"status": "succeeded",
"decided_by": "ai_auto",
"shopify_refund_id": 998877665544,
"failure_reason": null,
"processed_at": "2026-06-24T13:20:00Z",
"created_at": "2026-06-24T13:19:00Z"
}
],
"has_more": false,
"next_cursor": null
}
GET /refunds/{id}
Fetch a single refund.
Scope: refunds:manage
curl https://returnsiq.kernvellabs.com/api/public/v1/refunds/rfd_5521 \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
Notable errors: 404 not_found (no such refund for this store).
GET /returns/{id}/refunds
List the refunds issued against a return (usually one, occasionally several for partial outcomes).
Scope: refunds:manage
curl https://returnsiq.kernvellabs.com/api/public/v1/returns/ret_8fk3/refunds \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
{
"object": "list",
"data": [
{
"object": "refund",
"id": "rfd_5521",
"return_id": "ret_8fk3",
"method": "refund",
"amount": "116.10",
"currency": "USD",
"status": "succeeded",
"decided_by": "ai_auto",
"shopify_refund_id": 998877665544,
"failure_reason": null,
"processed_at": "2026-06-24T13:20:00Z",
"created_at": "2026-06-24T13:19:00Z"
}
],
"has_more": false,
"next_cursor": null
}
POST /returns/{id}/refunds
Issue a cash refund to the original payment method against a return. Idempotent — required. Emits refund.issued.
Scope: refunds:manage · Send Idempotency-Key (required)
| Field | Type | Required | Description |
|---|---|---|---|
amount |
number | yes | Gross refund amount in the return's currency (must be > 0) |
reason |
string | no | Recorded on the refund record (max 2000 chars) |
curl https://returnsiq.kernvellabs.com/api/public/v1/returns/ret_8fk3/refunds -X POST \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7e8f9a0b-1c2d-3e4f-5a6b-7c8d9e0f1a2b" \
-d '{ "amount": 116.10 }'
Returns the refund at 201:
{
"object": "refund",
"id": "rfd_5530",
"return_id": "ret_8fk3",
"method": "refund",
"amount": "116.10",
"currency": "USD",
"status": "succeeded",
"decided_by": "public_api",
"shopify_refund_id": 998877665544,
"failure_reason": null,
"processed_at": "2026-06-24T13:25:00Z",
"created_at": "2026-06-24T13:25:00Z"
}
Idempotency is mandatory here. If your network drops the response and you retry with the same
Idempotency-Key, you get the original refund back verbatim — the money moves once. Reusing the same key with a different body returns409 idempotency_conflict. Use a fresh UUID per distinct refund.
Notable errors: 409 conflict (return not in an approved/received state, or already fully refunded), 409 idempotency_conflict, 422 unprocessable (amount exceeds the refundable balance), 403 insufficient_scope (key lacks refunds:manage).
JavaScript (fetch) — issue a refund safely with idempotency
import { randomUUID } from "node:crypto";
const BASE = "https://returnsiq.kernvellabs.com/api/public/v1";
const KEY = process.env.RETURNSIQ_API_KEY;
async function issueRefund(returnId, amount) {
const res = await fetch(`${BASE}/returns/${returnId}/refunds`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": randomUUID(), // reuse this exact key if you retry
},
body: JSON.stringify({ amount }),
});
if (!res.ok) throw new Error(`Refund failed ${res.status}: ${await res.text()}`);
return res.json();
}
const refund = await issueRefund("ret_8fk3", 116.10);
console.log(`Issued ${refund.id} — ${refund.status}`);
Return Reasons
A return reason (object return_reason, id prefix rsn_) is an entry in your reason catalog — the dropdown a shopper picks from ("Wrong size", "Damaged", "Changed my mind"). The catalog is the union of Kernvel's built-in system reasons (available to every store) and any custom reasons you've added. Use the slug when creating returns.
GET /return_reasons
List the reasons available to your store — Kernvel's built-in system reasons plus any custom reasons you've added. Cursor-paginated.
Scope: return_reasons:read
| Param | Type | Description |
|---|---|---|
limit |
integer | 1–100, default 25 |
cursor |
string | next_cursor from a previous page |
curl https://returnsiq.kernvellabs.com/api/public/v1/return_reasons \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
{
"object": "list",
"data": [
{
"object": "return_reason",
"id": "rsn_1",
"slug": "wrong_size",
"label": "Wrong size",
"description": "Item didn't fit as expected.",
"sentiment_default": "neutral",
"is_active": true,
"sort_order": 10,
"system": true,
"created_at": "2026-05-01T08:00:00Z"
},
{
"object": "return_reason",
"id": "rsn_42",
"slug": "changed_mind",
"label": "Changed my mind",
"description": null,
"sentiment_default": "negative",
"is_active": true,
"sort_order": 20,
"system": false,
"created_at": "2026-05-12T10:30:00Z"
}
],
"has_more": false,
"next_cursor": null
}
system is true for Kernvel's built-in reasons (available to every store) and false for reasons you've added.
Notable errors: 403 insufficient_scope (key lacks return_reasons:read).
GET /return_reasons/{id}
Fetch a single return reason (a system reason or one of your own).
Scope: return_reasons:read
curl https://returnsiq.kernvellabs.com/api/public/v1/return_reasons/rsn_42 \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
Returns a single return_reason object (same shape as the list entries above).
Notable errors: 404 not_found (no such reason for this store), 403 insufficient_scope.
JavaScript (fetch) — build a reason lookup map
const BASE = "https://returnsiq.kernvellabs.com/api/public/v1";
const KEY = process.env.RETURNSIQ_API_KEY;
const res = await fetch(`${BASE}/return_reasons`, {
headers: { Authorization: `Bearer ${KEY}` },
});
const { data } = await res.json();
const labels = Object.fromEntries(data.map((r) => [r.slug, r.label]));
console.log(labels["wrong_size"]); // "Wrong size"
Policies
A policy (object policy, id prefix pol_) is a rule that decides what happens to a return automatically — a set of composable conditions (order age, item value, customer history) and an action (auto-approve, require a photo, send to manual review, auto-reject, or force a specific outcome). Policies are evaluated in priority order; the first match wins. Reading and writing policies lets you version-control your returns logic outside the app.
GET /policies
List your store's policies, newest first. Cursor-paginated. Each policy carries its own priority (lower = evaluated first; the first match wins).
Scope: policies:read
| Param | Type | Description |
|---|---|---|
limit |
integer | 1–100, default 25 |
cursor |
string | next_cursor from a previous page |
curl "https://returnsiq.kernvellabs.com/api/public/v1/policies?limit=50" \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
{
"object": "list",
"data": [
{
"object": "policy",
"id": "pol_7",
"name": "Auto-approve low-value, recent orders",
"description": "Frictionless returns under $100 within 30 days.",
"priority": 10,
"is_active": true,
"conditions": {
"operator": "AND",
"conditions": [
{ "type": "order_age_days", "op": "lt", "value": 30 },
{ "type": "item_value", "op": "lt", "value": 100 }
]
},
"actions": { "primary": "auto_approve" },
"times_matched": 412,
"times_approved": 401,
"times_rejected": 0,
"times_fraud_caught": 2,
"created_at": "2026-05-01T08:00:00Z"
}
],
"has_more": false,
"next_cursor": null
}
GET /policies/{id}
Fetch a single policy.
Scope: policies:read
curl https://returnsiq.kernvellabs.com/api/public/v1/policies/pol_7 \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
Returns a single policy object (same shape as the list entries above).
Notable errors: 404 not_found (no such policy for this store), 403 insufficient_scope.
POST /policies
Create a policy. Idempotent. Subject to your plan's policy cap. In test mode this returns a preview policy and does not create a real rule.
Scope: policies:write · Send Idempotency-Key
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Policy name |
description |
string | no | What the policy does |
priority |
integer | no | Lower = evaluated first (default 100) |
is_active |
boolean | no | Default true |
conditions |
object | no | Composable AND/OR/NOT tree (see below); defaults to {} |
actions |
object | no | { "primary": "...", "secondary": {...} }; defaults to {} |
The conditions and actions objects are stored as-is and interpreted by the app's decision engine. conditions supports nested operator (AND/OR/NOT) blocks; each leaf is { "type": "<field>", "op": "lt|lte|eq|gte|gt|in", "value": <v> }, with types such as order_age_days, item_value, customer_lifetime_returns, customer_vip. actions.primary is one of auto_approve, require_photo, manual_review, auto_reject, route_to_tier; actions.secondary can carry { "force_outcome": "store_credit", "restocking_fee_pct": 10 }.
curl https://returnsiq.kernvellabs.com/api/public/v1/policies -X POST \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e" \
-d '{
"name": "High-value items need a photo",
"priority": 20,
"conditions": {
"operator": "AND",
"conditions": [ { "type": "item_value", "op": "gte", "value": 250 } ]
},
"actions": { "primary": "require_photo" }
}'
Returns the created policy at 201.
Notable errors: 422 validation_failed (missing/blank name), 403 feature_not_in_plan (your plan's policy cap is reached), 409 idempotency_conflict.
JavaScript (fetch) — list policies in evaluation order
const BASE = "https://returnsiq.kernvellabs.com/api/public/v1";
const KEY = process.env.RETURNSIQ_API_KEY;
const res = await fetch(`${BASE}/policies`, {
headers: { Authorization: `Bearer ${KEY}` },
});
const { data } = await res.json();
for (const p of data.sort((a, b) => a.priority - b.priority)) {
console.log(`#${p.priority} ${p.name} → ${p.actions.primary}`);
}
Fraud Signals
A fraud signal (object fraud_signal, id prefix frd_) is the public, coarse risk assessment for a return. It exposes the merchant-actionable low/medium/high band plus a broad score bucket, not the exact score, model weights, confidence, thresholds, or cross-store network details. Every return is scored on submission; re-scoring (on photo upload or a merchant recompute) appends a new record, so this endpoint gives you a safe audit trail for downstream fraud tooling without exposing the internals someone could game.
GET /fraud_signals
List every fraud signal for your store, newest first. Cursor-paginated.
Scope: fraud_signals:read
| Param | Type | Description |
|---|---|---|
limit |
integer | 1–100, default 25 |
cursor |
string | next_cursor from a previous page |
return_id |
string | Filter to one return (ret_...) |
curl "https://returnsiq.kernvellabs.com/api/public/v1/fraud_signals?limit=50" \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
Returns a list of fraud_signal objects (same shape as below).
GET /returns/{id}/fraud_signals
List the fraud risk bands computed for a return, newest first.
Scope: fraud_signals:read
curl https://returnsiq.kernvellabs.com/api/public/v1/returns/ret_8fk3/fraud_signals \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
{
"object": "list",
"data": [
{
"object": "fraud_signal",
"id": "frd_3340",
"return_id": "ret_8fk3",
"risk_band": "low",
"score_band": "0-30",
"trigger": "initial_submit",
"created_at": "2026-06-24T09:14:05Z"
}
],
"has_more": false,
"next_cursor": null
}
Notable errors: 404 not_found (no such return for this store), 403 insufficient_scope (key lacks fraud_signals:read).
GET /fraud_signals/{id}
Fetch a single fraud signal by its id.
Scope: fraud_signals:read
curl https://returnsiq.kernvellabs.com/api/public/v1/fraud_signals/frd_3340 \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
Returns a single fraud_signal object (same shape as the list entries above).
Notable errors: 404 not_found (no such signal for this store), 403 insufficient_scope.
JavaScript (fetch) — surface the latest fraud breakdown
const BASE = "https://returnsiq.kernvellabs.com/api/public/v1";
const KEY = process.env.RETURNSIQ_API_KEY;
const res = await fetch(`${BASE}/returns/ret_8fk3/fraud_signals`, {
headers: { Authorization: `Bearer ${KEY}` },
});
const { data } = await res.json();
const latest = data[0]; // newest first
console.log(`Risk ${latest.score}/100 (${latest.risk_band}) — confidence ${latest.confidence}`);
console.table(latest.breakdown);
Account
The account object (account, id prefix shop_) is your store's plan, status, and API capabilities. Use it to discover what your current plan allows before attempting a write, and to read your live rate-limit snapshot.
GET /account
Read your plan, subscription status, currency, and API access block.
Scope: account:read
curl https://returnsiq.kernvellabs.com/api/public/v1/account \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
{
"object": "account",
"id": "shop_184",
"shop_domain": "northwind-apparel.myshopify.com",
"plan": "scale",
"subscription_status": "active",
"currency": "USD",
"test_mode": false,
"api_access": {
"live_keys": true,
"max_keys": 10,
"scopes": ["returns:read", "returns:write", "returns:manage", "refunds:manage",
"policies:read", "policies:write", "return_reasons:read",
"fraud_signals:read", "customers:read", "webhooks:manage", "account:read"],
"rate_per_min": 300,
"webhook_endpoints": 10
}
}
GET /account/usage
Read your current usage and live rate-limit counters.
Scope: account:read
curl https://returnsiq.kernvellabs.com/api/public/v1/account/usage \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
{
"object": "usage",
"plan": "scale",
"returns_this_month": 1284,
"returns_limit": 5000,
"rate_limit": 300,
"rate_limit_remaining": 297
}
(returns_limit is -1 on unlimited plans.)
Notable errors: 403 insufficient_scope (key lacks account:read).
JavaScript (fetch) — check a scope is available before writing
const BASE = "https://returnsiq.kernvellabs.com/api/public/v1";
const KEY = process.env.RETURNSIQ_API_KEY;
const res = await fetch(`${BASE}/account`, { headers: { Authorization: `Bearer ${KEY}` } });
const account = await res.json();
if (!account.api_access.scopes.includes("refunds:manage")) {
throw new Error(`Plan "${account.plan}" can't issue refunds via API — upgrade required.`);
}
Webhooks
Manage the endpoints we POST events to. Create a subscription to receive the webhook events below; the signing secret is returned once at creation — store it to verify deliveries.
POST /webhooks
Create a webhook subscription. The destination URL is validated (must be https and a public host).
New subscriptions return 201. Exact retries with the same URL, live/test environment, and event set return 200 with the existing subscription and an Idempotent-Replay: true response header. The signing secret is NOT re-returned on a replay (it is shown only once, on the original 201 create) — reuse the secret you stored then.
Scope: webhooks:manage
| Field | Type | Required | Description |
|---|---|---|---|
url |
string | yes | Your HTTPS endpoint |
events |
array | yes | One or more event types (see Webhook events) |
description |
string | no | A label for the subscription |
curl https://returnsiq.kernvellabs.com/api/public/v1/webhooks -X POST \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/hooks/returnsiq",
"events": ["return.requested", "return.approved", "return.denied", "refund.issued"],
"description": "Sync returns into our OMS"
}'
{
"object": "webhook_subscription",
"id": "whk_61",
"url": "https://yourapp.com/hooks/returnsiq",
"events": ["return.requested", "return.approved", "return.denied", "refund.issued"],
"description": "Sync returns into our OMS",
"env": "live",
"is_active": true,
"signing_secret": "whsec_3f9aZ8...shown_once",
"created_at": "2026-06-24T12:30:00Z"
}
Notable errors: 400 invalid_request (non-HTTPS or private/loopback URL), 422 invalid_request (unknown event type in events), 403 feature_not_in_plan (webhooks not on your plan, or endpoint cap reached).
GET /webhooks
List your subscriptions (the signing secret is never returned again).
Scope: webhooks:manage
curl https://returnsiq.kernvellabs.com/api/public/v1/webhooks \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
GET /webhooks/{id}
Fetch a single subscription (the signing secret is never returned again).
Scope: webhooks:manage
curl https://returnsiq.kernvellabs.com/api/public/v1/webhooks/whk_61 \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
Returns a single webhook_subscription object (without signing_secret).
Notable errors: 404 not_found (no such subscription for this store).
DELETE /webhooks/{id}
Delete a subscription. Deliveries stop immediately.
Scope: webhooks:manage
curl https://returnsiq.kernvellabs.com/api/public/v1/webhooks/whk_61 -X DELETE \
-H "Authorization: Bearer kv_ret_live_xxxxxxxx"
{ "object": "webhook_subscription", "id": "whk_61", "deleted": true }
Notable errors: 404 not_found (no such subscription for this store).
JavaScript (fetch) — subscribe to the return lifecycle
const BASE = "https://returnsiq.kernvellabs.com/api/public/v1";
const KEY = process.env.RETURNSIQ_API_KEY;
const res = await fetch(`${BASE}/webhooks`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
url: "https://yourapp.com/hooks/returnsiq",
events: ["return.requested", "return.approved", "return.denied", "refund.issued"],
}),
});
const sub = await res.json();
console.log("Store this secret now — it is shown once:", sub.signing_secret);
Webhook events
Subscribe to any of these on a webhook subscription. Every delivery is the standard envelope — id, type, api_version, created_at, and a data block carrying the resource in the same shape the REST API returns.
| Event | Fires when |
|---|---|
return.requested |
A customer submits a return |
return.approved |
A return is approved (by AI, policy, or staff) |
return.denied |
A return is rejected |
return.received |
Returned items are marked received at your warehouse |
refund.issued |
A refund, store credit, exchange, or repair is processed |
fraud.flagged |
A return's fraud risk crosses into the high band — band only, never the exact score |
Sample delivery (return.approved):
{
"id": "evt_b7c2",
"type": "return.approved",
"api_version": "2026-06-24",
"created_at": "2026-06-24T14:02:00Z",
"data": {
"object": "return",
"id": "ret_5d10",
"rma_code": "RMA-9X4L2",
"status": "under_review",
"reason": "not_as_described",
"currency": "USD",
"total_value": "640.00",
"fraud_risk_band": "high",
"shopify_order_id": 5012399988776,
"shopify_order_number": "#1098",
"item_count": 2,
"requested_at": "2026-06-24T13:58:00Z"
}
}
Sample delivery (fraud.flagged) — band only: the data block is the same coarse fraud_signal object the REST API returns, carrying just the risk_band and a broad score_band bucket. The exact 0-100 score, per-signal weights, confidence, thresholds, and cross-store network details are never included (they'd let an abuser reverse-engineer the model), so this is safe to route straight into your own risk tooling:
{
"id": "evt_f2a9",
"type": "fraud.flagged",
"api_version": "2026-06-24",
"created_at": "2026-06-24T14:05:00Z",
"data": {
"object": "fraud_signal",
"id": "frd_3340",
"return_id": "ret_5d10",
"risk_band": "high",
"score_band": "71-100",
"trigger": "photo_upload",
"created_at": "2026-06-24T14:05:00Z"
}
}
fraud.flagged fires once when a return crosses into the high band — on submission or on a later re-score (e.g. a photo upload that exposes a fake), not on every re-score that was already high. return.received fires when a return reaches the received state, whether you mark it received in the app, set its status via the API, or a carrier delivery webhook auto-advances it.
Always verify the X-Kernvel-Signature header before trusting a delivery: compute HMAC-SHA256(signing_secret, "<t>.<raw-body>"), compare it constant-time against the v1= value, and reject any delivery whose t= timestamp is older than 5 minutes. Test-mode subscriptions receive only test-mode events.