LatchPay

Insights and tracking

Abandoned-checkout webhook

Get each abandoned checkout, and each one the buyer pays after all, as a signed JSON POST to your own HTTPS endpoint.

Updated

Set it up

  1. Add your endpoint

    Settings → Abandoned-checkout webhook → Endpoint URL. An https:// URL on the standard port, reachable from the internet. Per store: each store has its own endpoint and secret.

  2. Copy the signing secret

    Saving a new endpoint shows the signing secret (whsec_…) once. Copy it into your receiver. Lost it? Rotate secret makes a new one; the old one stops working at once.

  3. Send a test event

    Send test event posts a sample right away and shows the answer, for example Delivered · HTTP 200 · 312 ms.

Sending starts with checkouts that are abandoned after you save the endpoint; older ones are not sent. For those, download the CSV on Insights (see Abandoned checkouts).

The two events

  • checkout.abandoned: a checkout with an email, not paid, quiet for 30 minutes. Sent once per checkout, usually within 5 minutes of that mark.
  • checkout.recovered: the same buyer paid after all. Sent once, after the abandoned event was delivered, with the order name. If the buyer pays before the abandoned event went out, neither is sent.

The payload

A JSON POST. data holds the same fields as the CSV columns, plus the cart lines. Money is a decimal string in the checkout's currency. No card data, IP address or browser details are ever included.

{
  "id": "5b0f7c1e-8a52-4d0e-9a3b-2f1c6d7e8a90",
  "type": "checkout.abandoned",
  "created_at": "2026-10-20T10:31:00.000Z",
  "test": false,
  "store": {
    "id": "0d3c1b2a-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
    "shop_domain": "my-store.myshopify.com",
    "name": "My Store"
  },
  "data": {
    "checkout_id": "7a1b2c3d-4e5f-4a6b-9c8d-0e1f2a3b4c5d",
    "started_at": "2026-10-20T09:46:00.000Z",
    "last_activity_at": "2026-10-20T10:00:00.000Z",
    "email": "anna@example.com",
    "phone": null,
    "first_name": "Anna",
    "last_name": "de Vries",
    "country": "NL",
    "cart_value": "74.40",
    "currency": "EUR",
    "items": [
      {
        "title": "Hoodie",
        "variant_title": "M",
        "quantity": 2,
        "price": "29.95",
        "line_price": "59.90",
        "sku": null
      },
      {
        "title": "Cap",
        "variant_title": null,
        "quantity": 1,
        "price": "14.50",
        "line_price": "14.50",
        "sku": null
      }
    ],
    "items_summary": "Hoodie – M × 2; Cap × 1",
    "step_reached": "contact_entered",
    "resume_link": "https://checkout.latchpay.io/checkout/test",
    "resume_link_expires_at": "2026-10-21T09:46:00.000Z",
    "recovered": false,
    "marketing_consent": true,
    "order": null
  }
}
  • resume_link reopens the buyer's checkout until resume_link_expires_at (24 hours after it started); after that it shows an expired page.
  • marketing_consent is true when the buyer ticked "Email me with news and offers".
  • test is true only for test events.

Headers and signature

Signed the Standard Webhooks way, so the standardwebhooks and Svix libraries verify it too.

content-type: application/json
webhook-id: 5b0f7c1e-8a52-4d0e-9a3b-2f1c6d7e8a90
webhook-timestamp: 1792492260
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
  • webhook-id: the event id. The same on every retry, so use it to ignore duplicates.
  • webhook-timestamp: Unix seconds of this attempt.
  • webhook-signature: v1, + base64 of HMAC-SHA256 over webhook-id.webhook-timestamp.body, keyed with the base64-decoded part of your secret after whsec_.

Verify it (Node.js)

import crypto from 'node:crypto';

// rawBody: the request body exactly as received (a string), not parsed and re-serialized JSON.
// secret: your signing secret from Settings, "whsec_…".
export function verifyLatchPay(rawBody, headers, secret) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'] || '';
  if (!id || !timestamp) return false;
  // refuse a timestamp more than 5 minutes away (replay protection)
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = crypto.createHmac('sha256', key).update(id + '.' + timestamp + '.' + rawBody).digest();
  return signatures.split(' ').some((part) => {
    const [version, value] = part.split(',');
    const got = Buffer.from(value || '', 'base64');
    return version === 'v1' && got.length === expected.length && crypto.timingSafeEqual(got, expected);
  });
}

With Express, read the raw body: express.raw({ type: 'application/json' }), then verifyLatchPay(req.body.toString('utf8'), req.headers, secret). Answer 401 when it returns false.

Answers, retries and switching off

  • Answer with any 2xx within 10 seconds. Do the slow work after answering.
  • Anything else is retried after 5 minutes, 30 minutes, 3 hours and 20 hours: 5 attempts over about a day. Redirects are not followed.
  • Events can arrive more than once or out of order. Use webhook-id and type.
  • After 15 failed deliveries in a row over more than 24 hours, LatchPay switches the webhook off and Insights shows a notice. Fix the endpoint, then Resume or save the URL again.
  • Settings lists the last 20 deliveries with their status, HTTP code and attempt, and when a retry is due. Queued means not tried yet.

Test events

A test event is a checkout.abandoned with "test": true and a sample buyer at an example.invalid address. It is sent once, shows in the delivery log, and never counts towards switching the webhook off.

Buyer data and consent

You decide who gets an email

The webhook sends your buyers' contact details to your endpoint. Email a buyer only as their consent (the marketing_consent field) or your legitimate-interest basis allows, and honour every opt-out. Keep the data no longer than you need it.

Pause stops sending; checkouts abandoned while paused are not sent later. Remove webhook deletes the endpoint and its delivery log. LatchPay keeps a delivery's body only until it is final, and the log for 30 days.

Still stuck?

Tell us what you see and which store it is about. We reply by email, usually within a day.