Skip to main content
GET /v2/merchant/shops/:shopId/abandoned-carts and GET /v2/merchant/shops/:shopId/abandoned-carts/stats are the merchant-facing surface for the abandoned-cart recovery admin page. They let the shop owner view their own recovery funnel — the paginated list of abandoned carts and the top-of-page stat cards (active / recovered / recoveryRate).
These endpoints are gated by RoleGuard([PRODUCER, SUPER_ADMIN]) PLUS an in-handler shop-ownership assertion: a PRODUCER’s JWT-bound shopId claim must equal the :shopId path parameter. Cross-shop reads from a merchant token return 403. SUPER_ADMIN tokens carry no shopId claim and pass through for operator impersonation.

Scope

These endpoints are the merchant-scoped counterpart to the SUPER_ADMIN admin surface at /admin/shops/:shopId/abandoned-carts. The original admin route remains mounted at its original path with its original guard — this is strictly additive. The recovery cron, schema, and write-path are untouched.
SurfacePathGuard
Public storefront resumePOST /v2/abandoned-cart-recovery/recoverNone (recovery token is the auth)
Merchant dashboard (this page)GET /v2/merchant/shops/:shopId/abandoned-carts*PRODUCER (own shop) or SUPER_ADMIN
Operator adminGET /admin/shops/:shopId/abandoned-carts*SUPER_ADMIN only

GET — list abandoned carts

GET /v2/merchant/shops/:shopId/abandoned-carts
Returns the merchant’s abandoned carts, sorted newest-abandonedAt-first, paginated.

Request

ParamTypeInNotes
shopIdstringpathThe shop’s Mongo _id. PRODUCER’s JWT-bound shopId MUST match.
statusenumquery, optionalABANDONED | EMAIL_QUEUED | EMAIL_SENT | RECOVERED | EXPIRED. Omitted = all states.
pageintquery, optional1-indexed page number. Default 1. Junk inputs clamp to 1.
pageSizeintquery, optionalDefault 100, max 500. Junk inputs clamp to 1.

Curl example

curl -s "https://apiv3.droplinked.com/v2/merchant/shops/65f8.../abandoned-carts?status=EMAIL_SENT&page=1&pageSize=20" \
  -H "Authorization: Bearer $MERCHANT_JWT" | jq .

Response (200)

{
  "rows": [
    {
      "_id": "65f8a1b2c3d4e5f6a7b8c9aa",
      "cartId": "65f8a1b2c3d4e5f6a7b8c9bb",
      "shopId": "65f8a1b2c3d4e5f6a7b8c9cc",
      "customerEmail": "buyer@example.com",
      "customerName": "Jamie Doe",
      "cartTotalCents": 5000,
      "currency": "USD",
      "status": "EMAIL_SENT",
      "abandonedAt": "2026-06-10T18:23:00.000Z",
      "emailSentAt": "2026-06-10T18:38:00.000Z",
      "expiresAt": "2026-06-17T18:23:00.000Z",
      "createdAt": "2026-06-10T18:23:00.000Z"
    }
  ],
  "total": 47,
  "page": 1,
  "pageSize": 20
}

Lifecycle states

statusMeaning
ABANDONEDThreshold crossed (cart with items + no checkout for N hours); awaiting next cron sweep
EMAIL_QUEUEDCron selected this row for sending
EMAIL_SENTEmail rail accepted the send (recovery URL is live)
RECOVEREDCustomer returned + checked out via the checkout-success chokepoint
EXPIREDPast expiresAt (7 days from abandonedAt) without recovery

Error responses

StatusWhen
401Missing/invalid JWT
403PRODUCER’s shopId claim does not match the path :shopId, or token lacks PRODUCER/SUPER_ADMIN role

GET — recovery stats

GET /v2/merchant/shops/:shopId/abandoned-carts/stats
Aggregate counts for the top-of-page stat cards. Client-side aggregation over the paginated list would produce a wrong recoveryRate past page 1 — this endpoint computes the totals server-side over the full collection.

Curl example

curl -s "https://apiv3.droplinked.com/v2/merchant/shops/65f8.../abandoned-carts/stats" \
  -H "Authorization: Bearer $MERCHANT_JWT" | jq .

Response (200)

{
  "activeCount": 12,
  "recoveredCount": 4,
  "recoveryRate": 0.25
}

Field reference

FieldTypeNotes
activeCountintRows in ABANDONED | EMAIL_QUEUED | EMAIL_SENT — carts still in the recovery funnel.
recoveredCountintRows in RECOVERED — the chokepoint flip happens at checkout-success (NOT email click-through), so this number directly tracks converted recoveries.
recoveryRatefloatrecoveredCount / (activeCount + recoveredCount), in [0, 1]. Zero-denominator edge case returns 0 (NOT NaN / null) — a brand-new shop with no abandoned-cart history would otherwise produce 0/0. The FE renders 0 as to avoid surfacing a misleading “0% recovery rate” before any data exists.
recoveryRate is checkout-success semantic, not click-through. A customer who clicks the recovery email but bounces off checkout does NOT count as recovered. The chokepoint is service.markRecovered invoked from the checkout-success path — see the POST /v2/abandoned-cart-recovery/recover endpoint that resumes the cart upstream of that flip.
Both countDocuments calls run in parallel and hit the existing {shopId, status, abandonedAt} compound index — covered counts, no collection scan.

Error responses

Same gating + 401/403 semantics as the list endpoint above.

Typical merchant dashboard flow

const BASE = "https://apiv3.droplinked.com";
const SHOP_ID = "65f8a1b2c3d4e5f6a7b8c9cc";
const JWT = process.env.MERCHANT_JWT!;

interface Stats {
  activeCount: number;
  recoveredCount: number;
  recoveryRate: number;
}

interface CartRow {
  _id: string;
  cartId: string;
  customerEmail: string;
  customerName: string | null;
  cartTotalCents: number;
  currency: string;
  status: "ABANDONED" | "EMAIL_QUEUED" | "EMAIL_SENT" | "RECOVERED" | "EXPIRED";
  abandonedAt: string;
}

interface ListResponse {
  rows: CartRow[];
  total: number;
  page: number;
  pageSize: number;
}

async function loadDashboard(page = 1) {
  const headers = { Authorization: `Bearer ${JWT}` };
  const [stats, list] = await Promise.all([
    fetch(`${BASE}/v2/merchant/shops/${SHOP_ID}/abandoned-carts/stats`, { headers })
      .then((r) => r.json() as Promise<Stats>),
    fetch(
      `${BASE}/v2/merchant/shops/${SHOP_ID}/abandoned-carts?page=${page}&pageSize=20`,
      { headers },
    ).then((r) => r.json() as Promise<ListResponse>),
  ]);

  // Render stat cards: "—" when recoveryRate === 0 + no carts at all
  const rateLabel =
    stats.activeCount + stats.recoveredCount === 0
      ? "—"
      : `${(stats.recoveryRate * 100).toFixed(1)}%`;

  return { stats, list, rateLabel };
}