---
title: "Receiving CartGenie webhooks with the Node SDK"
description: "CartGenie's official typescript SDK for webhooks"
url: https://help.cartgenie.com/en/articles/240-receiving-cartgenie-webhooks-with-the-node-sdk
collection: "Developers"
updated: 2026-10-09
language: en
---

# Receiving CartGenie webhooks with the Node SDK

CartGenie's official typescript SDK for webhooks

`@cartgenie/webhooks` is the official TypeScript SDK for receiving webhook deliveries from a CartGenie store. It verifies the delivery signature, parses the event, and routes it to typed handlers you write.

This guide covers installing the SDK, building a receiver, going live, and diagnosing the failures that come up most often.

Download SDK

**Who this is for:** the developer building your store's webhook endpoint. If you only want to know what webhooks are available, see the event table in _Events you can receive_ below and stop there.

**If you are looking for CartGenie’s no-code webhooks interface, read our guide here:**

[

Using Webhooks in CartGenie

How to Connect CartGenie to Other Apps with Webhooks

](https://help.cartgenie.com/en/articles/95-using-webhooks-in-cartgenie.md)

* * *

## What this SDK does and does not do

<table data-id="72d76c08-4fee-429a-961d-e30fb3623a98" style="min-width: 50px;"><colgroup><col style="min-width: 25px;"><col style="min-width: 25px;"></colgroup><tbody><tr><td colspan="1" rowspan="1"><p data-id="afc33beb-c167-4027-a51a-b3be4ec0eaec"><strong>Does</strong></p></td><td colspan="1" rowspan="1"><p data-id="fc033c66-a9eb-4f13-8c96-9f38debd3505">Verifies the <code>Signature</code> header against the raw request bytes</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="dff445d2-f1f8-4083-95b6-e35fed74bbdc"></p></td><td colspan="1" rowspan="1"><p data-id="96501f37-0bec-4c3b-bc08-4bbd4f3e491c">Parses the delivery envelope into a typed event</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="d006505e-8ea6-4b31-a7e6-fa29a399b4f3"></p></td><td colspan="1" rowspan="1"><p data-id="7f86d065-99b0-4f61-b3b2-cf928551b94e">Dispatches to handlers you register per event type</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="db9450af-0d6b-4f65-819c-605c7a28a9f3"></p></td><td colspan="1" rowspan="1"><p data-id="17256426-700c-4b91-820b-d70dd40054b2">Provides framework adapters that return the correct HTTP status codes</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="77cda81a-92fb-4e14-96bb-c0ad96338381"><strong>Does not</strong></p></td><td colspan="1" rowspan="1"><p data-id="3d5efbc6-811b-4720-9f3d-17bdc93bbcc0">Create, list, or delete webhook subscriptions</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="f0152ab4-5647-40bd-a4d2-7621ee1d28d4"></p></td><td colspan="1" rowspan="1"><p data-id="ce6c2afa-9c44-4903-b3dd-5a0021c312ef">Call the CartGenie API for anything</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="5f313989-d872-4e45-8f74-e97e8f6ed46a"></p></td><td colspan="1" rowspan="1"><p data-id="5c2dbbd4-faca-4b90-b866-3218138edcdd">Retry, queue, or deduplicate deliveries</p></td></tr></tbody></table>

The SDK is **receive-only**. It makes no outbound network calls. Setting up _which_ events go to _which_ URL happens in your CartGenie dashboard, under **Settings → Webhooks**.

If you need to read or change store data in response to an event, that is a separate job for the CartGenie API — the webhook tells you something changed, the API lets you act on it.

* * *

## Before you start

You need:

-   **Node.js 20 or newer**, or any runtime with Web Crypto and `fetch` — Deno, Bun, Cloudflare Workers, Vercel Edge all work.
    
-   **A publicly reachable HTTPS URL** with a valid certificate. CartGenie will not deliver to `localhost`, private IP ranges, or plain HTTP. For local development use a tunnel such as ngrok or Cloudflare Tunnel.
    
-   **Your store's webhook signing secret**, from **Settings → Webhooks** in the dashboard.
    

One signing secret covers every webhook subscription on the store. Treat it like a password: store it in an environment variable, never in source control or client-side code.

* * *

## Install

```bash
npm install @cartgenie/webhooks
```

The package has zero runtime dependencies and ships both ESM and CommonJS builds with TypeScript types.

* * *

## Build a receiver

### 1\. Create a client

```ts
import { CartGenieWebhooks } from '@cartgenie/webhooks';

export const cg = new CartGenieWebhooks({
  secret: process.env.CARTGENIE_WEBHOOK_SECRET!,
});
```

### 2\. Register handlers

```ts
cg.on('new_order', async ({ payload }) => {
  await recordOrder(payload.order_id, payload.total, payload.currency_code);
});

cg.on('new_abandoned_cart', async ({ payload }) => {
  await sendRecoveryEmail(payload.email, payload.guid);
});
```

`on()` returns the client, so calls chain. Handlers for the same event run in registration order, one after another, and each is awaited before the next starts.

Three registration methods are available:

-   `cg.on(type, handler)` — one known event type, fully typed payload.
    
-   `cg.onUnknown(handler)` — event types this SDK version does not recognize.
    
-   `cg.onAny(handler)` — every event, known and unknown. Runs _after_ the per-event and unknown handlers. Narrow with `isKnownWebhookEvent(event)` before touching `payload`.
    

### 3\. Mount the route

Pick the adapter for your framework. All of them read the raw request body correctly, which is required for signature verification.

**Next.js (App Router)**

```ts
// app/api/cartgenie-webhook/route.ts
import { createNextRouteHandler } from '@cartgenie/webhooks/next';
import { cg } from '@/lib/cartgenie';

export const POST = createNextRouteHandler(cg);
```

**Express**

```ts
import express from 'express';
import { createExpressWebhookMiddleware } from '@cartgenie/webhooks/express';

app.post(
  '/cartgenie-webhook',
  express.raw({ type: 'application/json' }), // required — see note below
  createExpressWebhookMiddleware(cg),
);
```

The `express.raw()` middleware is not optional. If a JSON body parser runs first, the signature check will fail on every delivery. Note that `express.raw()` applies its own body-size limit, 100 kb by default.

**Remix, Bun, Deno, Cloudflare Workers, or any Web-standard runtime**

```ts
import { createWebhookHandler } from '@cartgenie/webhooks/web';

export default { fetch: createWebhookHandler(cg) };
```

**No framework**

```ts
import { parseWebhook, isKnownWebhookEvent } from '@cartgenie/webhooks';

const event = await parseWebhook({
  rawBody: await request.arrayBuffer(), // raw bytes, never re-serialized JSON
  signature: request.headers.get('Signature'),
  secret: process.env.CARTGENIE_WEBHOOK_SECRET!,
});

if (isKnownWebhookEvent(event) && event.type === 'inventory_updated') {
  console.log(event.payload.stock);
}
```

### 4\. Subscribe in the dashboard

Go to **Settings → Webhooks**, add your endpoint URL, and select the events you want. Use **Send test** to fire a sample delivery through the real pipeline.

* * *

## Test locally

You do not need a live store to test your handler logic. The signature is an HMAC-SHA256 of the raw body, so you can generate a valid delivery yourself:

```ts
import { createHmac } from 'node:crypto';
import { CartGenieWebhooks } from '@cartgenie/webhooks';

const secret = 'whsec_test';
const body = JSON.stringify({
  type: 'new_order',
  payload: { order_id: 'ord_1', total: 114000, currency_code: 'USD' },
});
const signature = createHmac('sha256', secret).update(body).digest('hex');

const cg = new CartGenieWebhooks({ secret });
cg.on('new_order', ({ payload }) => console.log('received', payload.order_id));

await cg.handle({ rawBody: body, signature });
```

This is the fastest way to write unit tests for your handlers. Pair it with a real **Send test** delivery from the dashboard before you go live, so you exercise the network path too.

* * *

## Events you can receive

`type`

Payload

Fired when

<table data-id="df6e8807-88e4-4744-9ced-619bd3b22117" style="min-width: 75px;"><colgroup><col style="min-width: 25px;"><col style="min-width: 25px;"><col style="min-width: 25px;"></colgroup><tbody><tr><td colspan="1" rowspan="1"><p data-id="e10def7b-cad7-4ffd-afff-b63349aaa501"><code>new_order</code></p></td><td colspan="1" rowspan="1"><p data-id="d2cabedf-084e-4237-b08e-625d813f6d00"><code>OrderPayload</code></p></td><td colspan="1" rowspan="1"><p data-id="239c5abb-dbc3-4e83-90fd-574037e92d32">An order is placed. Not necessarily paid — offline payments arrive with <code>payment.status: "unpaid"</code></p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="fd796fb5-be32-4256-a372-e41e87ebb746"><code>order_updated</code></p></td><td colspan="1" rowspan="1"><p data-id="2bf876ac-a8a2-44a9-8923-8aebaa7e9a53"><code>OrderPayload</code></p></td><td colspan="1" rowspan="1"><p data-id="b73ec99d-a460-4bb5-98aa-7054d2b615e0">Fulfillment status changes, the order is canceled, or tracking is updated</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="c4cb3d67-ae17-495c-bceb-579252717859"><code>order_fulfillment_updated</code></p></td><td colspan="1" rowspan="1"><p data-id="603a5f3d-fd19-4965-9a15-f4e77ee14a5d"><code>OrderPayload</code></p></td><td colspan="1" rowspan="1"><p data-id="1270eaad-6592-42d6-8fd6-afa0ecc2f5f2">A fulfillment is created or changed</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="603c5a8b-176d-441c-9cb6-c2cb8fab9663"><code>refund_issued</code></p></td><td colspan="1" rowspan="1"><p data-id="c8307db0-fb50-4e84-9b04-a519634652ad"><code>RefundIssuedPayload</code></p></td><td colspan="1" rowspan="1"><p data-id="e6ad4226-ba28-42ef-9706-2896e73b035c">A refund is issued — <code>OrderPayload</code> plus <code>refund_amount</code></p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="79a9032c-85f4-4bb3-b845-34b49ad38447"><code>new_customer</code></p></td><td colspan="1" rowspan="1"><p data-id="bce3583b-7ddb-45f9-8c83-22e2b5fa3295"><code>CustomerPayload</code></p></td><td colspan="1" rowspan="1"><p data-id="6366e0c7-002e-49fc-a83e-bf2cd3f45fa5">A customer record is created</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="b6c72f08-f942-4802-93fc-def472f618ba"><code>customer_updated</code></p></td><td colspan="1" rowspan="1"><p data-id="161c7f87-e108-4f26-b5bb-cc02a38b7847"><code>CustomerPayload</code></p></td><td colspan="1" rowspan="1"><p data-id="e875e0a5-5817-4bc7-a539-fcc61d357c5f">A customer record changes</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="6be54ea4-9c67-4d54-8ec3-f4e330385bc2"><code>new_subscription</code></p></td><td colspan="1" rowspan="1"><p data-id="692f3ccd-70f7-485d-a9c1-17b89ae63a7b"><code>SubscriptionPayload</code></p></td><td colspan="1" rowspan="1"><p data-id="61f374c3-e429-4c5d-9a8f-20d4fc1a55bc">A subscription starts</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="a3784130-a147-469b-92f4-84b11c1c3184"><code>subscription_updated</code></p></td><td colspan="1" rowspan="1"><p data-id="49a82816-4b1e-4985-9c7e-e0889ba9c48a"><code>SubscriptionPayload</code></p></td><td colspan="1" rowspan="1"><p data-id="e2276d63-e087-496c-bb7e-51e38e2988cb">A subscription is canceled, paused, or resumed</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="bab619c6-5e64-451c-8854-32d6357b2584"><code>new_subscription_charge</code></p></td><td colspan="1" rowspan="1"><p data-id="dfec637a-6576-44ce-a373-41ace70e3e79"><code>SubscriptionPayload</code></p></td><td colspan="1" rowspan="1"><p data-id="5e0f4020-42a2-42d9-8523-7f65d0dde779">A recurring charge succeeds</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="a13d08d5-2369-41c4-85f9-8622a1ad58bb"><code>new_discount</code></p></td><td colspan="1" rowspan="1"><p data-id="15f87c23-d5be-4ea1-a522-7d37c46cd21b"><code>DiscountPayload</code></p></td><td colspan="1" rowspan="1"><p data-id="dcc49cb1-4eda-446d-a3d7-46c982c6cf65">A discount is created</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="a894bf8b-5047-4c5b-ab9f-94b0b403f76f"><code>discount_updated</code></p></td><td colspan="1" rowspan="1"><p data-id="15181bb6-a1b9-44a9-94fd-61a9232e730a"><code>DiscountPayload</code></p></td><td colspan="1" rowspan="1"><p data-id="07063034-2632-4c46-9240-16d502286aa5">A discount is updated or disabled</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="df90bc15-ee07-4e6b-8020-19e157d236cb"><code>new_category</code></p></td><td colspan="1" rowspan="1"><p data-id="d32aa60c-fd2e-4bfb-9ed3-dc9f566c1504"><code>CategoryPayload</code></p></td><td colspan="1" rowspan="1"><p data-id="f841d93b-2002-457e-8679-84193b5359f3">A category is created</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="3a80399f-b18a-4f20-b222-a385e71eabb1"><code>category_updated</code></p></td><td colspan="1" rowspan="1"><p data-id="db521e8e-c9a8-408e-b4d3-d8e1789e6581"><code>CategoryPayload</code></p></td><td colspan="1" rowspan="1"><p data-id="867b5daa-48f5-4150-a808-230c5465732a">A category changes</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="4c73d759-48aa-41d0-8644-d5a46b8c2615"><code>new_product</code></p></td><td colspan="1" rowspan="1"><p data-id="b73ad5a1-083e-44ca-8ca4-9967f53e78f0"><code>ProductPayload</code></p></td><td colspan="1" rowspan="1"><p data-id="f7979f17-67d2-40d6-b3cc-dd3072aa6079">A product is created</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="6c90e07b-fc98-42f5-986a-6733f8e470db"><code>product_updated</code></p></td><td colspan="1" rowspan="1"><p data-id="0efe65b5-53d3-4e12-8519-3fe04906202a"><code>ProductPayload</code></p></td><td colspan="1" rowspan="1"><p data-id="c6d31bd6-ccc2-44ce-b754-79006903959b">A product changes</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="41c7ed18-2424-4f6f-a538-969c1824c151"><code>new_abandoned_cart</code></p></td><td colspan="1" rowspan="1"><p data-id="7419bc54-51ad-4bd4-a89c-40158bafd0f9"><code>AbandonedCartPayload</code></p></td><td colspan="1" rowspan="1"><p data-id="14e1567e-ecb8-4197-85cb-222dfddedb4a">A cart is marked abandoned</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="c3992e6e-f389-40f8-b0c9-680a23c579fb"><code>inventory_updated</code></p></td><td colspan="1" rowspan="1"><p data-id="19c3690b-d18a-496c-b21f-fecf3db96d8b"><code>InventoryPayload</code></p></td><td colspan="1" rowspan="1"><p data-id="ba25b746-1e9e-4637-ad05-e179972f7b40">A variant's stock changes</p></td></tr></tbody></table>

The full list is exported at runtime as `WEBHOOK_EVENTS`, and every payload interface is exported from the package root.

CartGenie adds event types over time. New types route to `onUnknown` rather than throwing, so an older SDK version keeps working when new events ship.

* * *

## The delivery contract

Every delivery is an HTTPS `POST` with:

-   **Body:** `{ "type": "<event>", "payload": { … } }`
    
-   `Content-Type`**:** `application/json`
    
-   `Signature`**:** lowercase hex `HMAC-SHA256(rawBody, secret)`
    

### Respond fast

CartGenie waits 10 seconds for your response. Anything slower counts as a failed attempt. Acknowledge with a 2xx immediately and move heavy work — image processing, third-party API calls, report generation — onto a queue.

### Retries are automatic

CartGenie attempts each delivery up to 3 times with exponential backoff. Your receiver never has to ask for a retry: return HTTP 500 and the delivery is retried. The adapters do this automatically when one of your handlers throws.

### Deliveries are at-least-once and unordered

A slow-but-successful response can still produce a duplicate delivery, and two events seconds apart can arrive in reverse order.

Deliveries carry no event ID, so **deduplicate on natural keys in the payload** — `order_id` or `guid` for orders, `sku` for variants. And key your decisions off state fields in the payload, such as `fulfillment_state`, rather than off arrival order. Never write logic that assumes event B arrived after event A.

### Disabling a subscription from your side

If your endpoint returns `410 Gone`, CartGenie disables that subscription immediately. Use this deliberately — an accidental 410 from a misconfigured router will silently stop your integration.

* * *

## Payload conventions

These trip people up, so read them once before you write parsing code.

**Money is in hundredths of the major currency unit.** `114000` means `$1,140.00`. Locale-formatted `formatted_*` strings ride alongside most integer fields. The abandoned-cart payload is the exception — it carries formatted strings only, so segmenting carts by value means parsing the string.

**Some fields are machine enums, others are human labels.** `order.status`, `payment.status`, and `activation_type` are enums. `discount_type` is `"Percentage"`; `product.status` is `"Published"`. The TypeScript types encode which is which — trust them over intuition.

**Empty maps arrive as empty arrays.** An option-less variant's `options`, an order with no shipment (`shipment: []`), and empty personalization or custom-field maps all come through as `[]`. `refunds` is the inverse: `{}` until the order is refunded, then an array. The types encode each case.

**Order payloads carry both** `items` **and** `products`**.** These are historical duplicates kept for backward compatibility. Prefer `items`.

`options` **changes shape by event.** It is a list of option rows on order and cart items, but a flat string map on `inventory_updated`.

**Bundle component quantities are pre-multiplied** by the line quantity on order items.

**Test deliveries approximate real ones.** In particular, `inventory_updated.previous_stock` is absent on test payloads. Do not assume optional fields are present just because a test delivery succeeded.

* * *

## Signature verification: the one thing to get right

The signature covers the **raw request bytes**, not the parsed object.

If you parse the JSON and re-serialize it before verifying, verification will fail — sometimes. CartGenie escapes non-ASCII characters as `\uXXXX` on the wire, while `JSON.stringify()` in JavaScript emits them literally. A payload containing `Åsa` goes out as `\u00c5sa` and comes back from a re-serialization round trip as `Åsa`. Different bytes, different HMAC.

The failure mode is nasty because it is intermittent: everything works until your first customer with an accent in their name, or your first product with a curly quote in the title.

Every adapter in this package reads the raw body correctly. If you are writing your own integration, pass `rawBody` straight through from the request — `Buffer`, `Uint8Array`, `ArrayBuffer`, and `string` are all accepted.

### What verification does and does not prove

The signature proves the delivery came from your store and was not modified in transit. There is **no timestamp or nonce** in the scheme, so it does not prove freshness. Anyone who captures a valid delivery can replay it later and the signature will still verify.

For most integrations this is fine. If a webhook triggers something irreversible or financial on your side, add your own protection — record processed `order_id` values and ignore repeats, or require a state transition to actually be a transition before acting.

* * *

## HTTP responses from the adapters

Status

Meaning

CartGenie retries?

<table data-id="40ec7f2f-c0ec-4f15-9c50-ba0f317c5f79" style="min-width: 75px;"><colgroup><col style="min-width: 25px;"><col style="min-width: 25px;"><col style="min-width: 25px;"></colgroup><tbody><tr><td colspan="1" rowspan="1"><p data-id="5912a215-1961-4665-9c15-d68fd65bc3b0"><code>200</code></p></td><td colspan="1" rowspan="1"><p data-id="4bc672a1-a745-4b84-950f-2c0ac687bd8f">Verified, parsed, handlers ran</p></td><td colspan="1" rowspan="1"><p data-id="0b4d179a-9d19-4339-88aa-70969a334d36">No</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="a91a3995-c667-43de-830f-e1c6bb1c5ddc"><code>400</code></p></td><td colspan="1" rowspan="1"><p data-id="d58207b7-65e4-4f41-9324-a143aa9f587f">Body is not valid JSON or not a valid envelope</p></td><td colspan="1" rowspan="1"><p data-id="3f68e8dc-4b5e-4003-a6e6-48bae2c7c6fd">Yes</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="26953968-df89-4a13-9ea1-92cd16c0d97d"><code>401</code></p></td><td colspan="1" rowspan="1"><p data-id="f2a61d90-70cf-4256-95a2-e58af54f848f">Missing or invalid <code>Signature</code> header</p></td><td colspan="1" rowspan="1"><p data-id="1ff43766-bf5c-409c-927e-fce45170bebd">Yes</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="79227d08-fb4f-4fa5-a799-9d8e6fb02405"><code>405</code></p></td><td colspan="1" rowspan="1"><p data-id="70c51bad-64e0-4a92-abd0-d6defad6e393">Request was not a <code>POST</code></p></td><td colspan="1" rowspan="1"><p data-id="10d38120-3532-49fd-96d3-6488ef0bdfba">Yes</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="6b37901a-8ecd-40ae-8f0d-743c72a23004"><code>413</code></p></td><td colspan="1" rowspan="1"><p data-id="b21682a4-d0a0-47f1-b054-de3758df6a7d">Body exceeded <code>maxBodyBytes</code> (web/Next only, default 5 MiB)</p></td><td colspan="1" rowspan="1"><p data-id="2d41d43a-f0fd-4890-9e87-3c889c40e511">Yes</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="6b53f362-9b06-4f4d-af0c-f64701a4b900"><code>415</code></p></td><td colspan="1" rowspan="1"><p data-id="1ea64ca0-82bd-4e99-bf9d-194d8190bde4">Wrong content type (Express only)</p></td><td colspan="1" rowspan="1"><p data-id="3996a95b-4529-4455-8b2f-c8b3bc31d571">Yes</p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="e73711f4-a9a7-4474-a6e8-72c91557d1ce"><code>500</code></p></td><td colspan="1" rowspan="1"><p data-id="4bf32d2a-31d6-4346-b781-6e449d26da47">One of your handlers threw</p></td><td colspan="1" rowspan="1"><p data-id="84a9bfdd-ff45-4587-9775-73642dcf2a54">Yes</p></td></tr></tbody></table>

Express returns `500` with a "raw body required" message if the route is mounted without `express.raw()`.

* * *

## Monitoring

Both adapters accept two hooks:

```ts
createWebhookHandler(cg, {
  onProcessed: (event) => metrics.increment(`webhook.${event.type}`),
  onError: (error, request) => logger.error({ err: error }, 'webhook failed'),
});
```

-   `onProcessed` runs after a delivery is verified, parsed, and dispatched. If it throws, the error goes to `onError` but the response stays `200` — the delivery was already handled.
    
-   `onError` runs when one of your handlers throws, or (Express only) when the route is misconfigured. Errors thrown inside `onError` are swallowed.
    

> **Important:** `onError` does **not** fire for `401` or `400` responses. Rejected deliveries return early, before any hook runs. This matters because a wrong signing secret — the most common setup mistake — produces no `onError` signal at all. **Alert on 401 responses from your webhook route at the load balancer, reverse proxy, or platform log level**, not through this hook.

* * *

## Troubleshooting

### Every delivery returns 401

In order of likelihood:

1.  **The signing secret is wrong or stale.** Copy it again from **Settings → Webhooks**. Check the environment variable is actually loaded in the deployed environment, not just locally.
    
2.  **A body parser is running before the adapter.** On Express, confirm `express.raw({ type: 'application/json' })` is mounted on this route specifically, and that no global `express.json()` runs ahead of it.
    
3.  **A proxy or WAF is rewriting the body.** Some gateways normalize or re-encode JSON payloads in transit, which changes the bytes.
    
4.  **You are testing with a hand-built request** whose signature was computed over different bytes than were sent.
    

### Deliveries arrive but handlers never run

Check the event type string matches exactly. A subscription for `order_updated` will not trigger an `on('new_order')` handler. Add a temporary `cg.onAny((e) => console.log(e.type))` to see what is actually arriving.

### Handlers run twice for the same order

Expected. Deliveries are at-least-once. Deduplicate on `order_id` or `guid`.

Also check you have not registered the same handler function twice — the SDK does not deduplicate registrations, so registering a function twice runs it twice per delivery.

### The subscription stopped delivering

Your endpoint returned `410 Gone` at some point, which disables the subscription permanently. Re-enable it in the dashboard and find the source of the 410 — often a router returning it for an unmatched path.

### Works in staging, fails in production

Usually one of: a different signing secret per store, TLS certificate problems on the production hostname, or a CDN or WAF in front of production that is not in front of staging.

### Timeouts under load

Your handler is doing too much synchronously. Return `200` first and queue the work. Remember handlers run sequentially and are all awaited before the adapter responds — three handlers taking four seconds each will blow the 10-second budget.

* * *

## Reference

### Exports

```ts
// Client
new CartGenieWebhooks({ secret })
  .on(type, handler)
  .onAny(handler)
  .onUnknown(handler)
  .handle({ rawBody, signature })   // → Promise<WebhookEvent>

// Functions
verifySignature({ rawBody, signature, secret })  // → Promise<boolean>
parseWebhook({ rawBody, signature, secret })     // → Promise<WebhookEvent>
isKnownWebhookEvent(event)                       // type guard

// Constants
WEBHOOK_EVENTS                                   // readonly string[]

// Errors
CartGenieWebhookError                            // base class
SignatureVerificationError
InvalidPayloadError
```

### Adapters

Import

Export

Options

<table data-id="ba8d917b-ec26-48ae-a3cf-f1f936aab87c" style="min-width: 75px;"><colgroup><col style="min-width: 25px;"><col style="min-width: 25px;"><col style="min-width: 25px;"></colgroup><tbody><tr><td colspan="1" rowspan="1"><p data-id="eb156c19-727c-432c-8503-f766799ef145"><code>@cartgenie/webhooks/express</code></p></td><td colspan="1" rowspan="1"><p data-id="73021637-93e5-41de-8ea5-f2b7d2551756"><code>createExpressWebhookMiddleware</code></p></td><td colspan="1" rowspan="1"><p data-id="fcba9962-e6b0-4b9b-88e1-9fb5de7eba6e"><code>onProcessed</code>, <code>onError</code></p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="2cfcdd33-951f-4a4a-a3eb-a58535b01463"><code>@cartgenie/webhooks/next</code></p></td><td colspan="1" rowspan="1"><p data-id="776db139-395b-499e-b2b3-3a7994b969a1"><code>createNextRouteHandler</code></p></td><td colspan="1" rowspan="1"><p data-id="c1fbd007-dc30-47bf-a850-fc76340d579f"><code>maxBodyBytes</code>, <code>onProcessed</code>, <code>onError</code></p></td></tr><tr><td colspan="1" rowspan="1"><p data-id="eb0a553f-b64d-4b38-9636-9d3aa7f884b1"><code>@cartgenie/webhooks/web</code></p></td><td colspan="1" rowspan="1"><p data-id="9059c4d3-9a61-4f8c-9ef1-0eb4ff95e3ca"><code>createWebhookHandler</code></p></td><td colspan="1" rowspan="1"><p data-id="46e2311f-0586-4880-a870-041b6aab6193"><code>maxBodyBytes</code>, <code>onProcessed</code>, <code>onError</code></p></td></tr></tbody></table>

### Not included in v1

-   No receiver-side retries or queueing — the sender retries; bring your own queue for heavy work.
    
-   No replay protection — the signing scheme has no timestamp.
    
-   No idempotency store — deduplicate on natural keys.
    
-   No REST API client — webhook subscriptions are managed in the dashboard.
    

* * *

## Getting help

-   **Package:** `@cartgenie/webhooks` on npm
    
-   **Source and issues:** [github.com/monto/cartgenie-webhooks-sdk](https://github.com/monto/cartgenie-webhooks-sdk)
    
-   **License:** MIT
    

When reporting a problem, include the event type, the HTTP status your endpoint returned, and your SDK version. Never paste your signing secret into a support ticket.
