company logo

Help center

Go to CartGenie
App DashboardCartGenie Website[email protected]
All collectionsDevelopersReceiving CartGenie webhooks with the Node SDK

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.

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


What this SDK does and does not do

Does

Verifies the Signature header against the raw request bytes

Parses the delivery envelope into a typed event

Dispatches to handlers you register per event type

Provides framework adapters that return the correct HTTP status codes

Does not

Create, list, or delete webhook subscriptions

Call the CartGenie API for anything

Retry, queue, or deduplicate deliveries

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

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

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

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

2. Register handlers

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)

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

export const POST = createNextRouteHandler(cg);

Express

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

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

export default { fetch: createWebhookHandler(cg) };

No framework

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:

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

new_order

OrderPayload

An order is placed. Not necessarily paid — offline payments arrive with payment.status: "unpaid"

order_updated

OrderPayload

Fulfillment status changes, the order is canceled, or tracking is updated

order_fulfillment_updated

OrderPayload

A fulfillment is created or changed

refund_issued

RefundIssuedPayload

A refund is issued — OrderPayload plus refund_amount

new_customer

CustomerPayload

A customer record is created

customer_updated

CustomerPayload

A customer record changes

new_subscription

SubscriptionPayload

A subscription starts

subscription_updated

SubscriptionPayload

A subscription is canceled, paused, or resumed

new_subscription_charge

SubscriptionPayload

A recurring charge succeeds

new_discount

DiscountPayload

A discount is created

discount_updated

DiscountPayload

A discount is updated or disabled

new_category

CategoryPayload

A category is created

category_updated

CategoryPayload

A category changes

new_product

ProductPayload

A product is created

product_updated

ProductPayload

A product changes

new_abandoned_cart

AbandonedCartPayload

A cart is marked abandoned

inventory_updated

InventoryPayload

A variant's stock changes

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?

200

Verified, parsed, handlers ran

No

400

Body is not valid JSON or not a valid envelope

Yes

401

Missing or invalid Signature header

Yes

405

Request was not a POST

Yes

413

Body exceeded maxBodyBytes (web/Next only, default 5 MiB)

Yes

415

Wrong content type (Express only)

Yes

500

One of your handlers threw

Yes

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


Monitoring

Both adapters accept two hooks:

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

// 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

@cartgenie/webhooks/express

createExpressWebhookMiddleware

onProcessed, onError

@cartgenie/webhooks/next

createNextRouteHandler

maxBodyBytes, onProcessed, onError

@cartgenie/webhooks/web

createWebhookHandler

maxBodyBytes, onProcessed, onError

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

  • 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.

Did this answer your question?
😞
😐
😁