@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
Does | Verifies the |
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.
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.
npm install @cartgenie/webhooks
The package has zero runtime dependencies and ships both ESM and CommonJS builds with TypeScript types.
import { CartGenieWebhooks } from '@cartgenie/webhooks';
export const cg = new CartGenieWebhooks({
secret: process.env.CARTGENIE_WEBHOOK_SECRET!,
});
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.
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);
}
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.
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.
type
Payload
Fired when
|
| An order is placed. Not necessarily paid — offline payments arrive with |
|
| Fulfillment status changes, the order is canceled, or tracking is updated |
|
| A fulfillment is created or changed |
|
| A refund is issued — |
|
| A customer record is created |
|
| A customer record changes |
|
| A subscription starts |
|
| A subscription is canceled, paused, or resumed |
|
| A recurring charge succeeds |
|
| A discount is created |
|
| A discount is updated or disabled |
|
| A category is created |
|
| A category changes |
|
| A product is created |
|
| A product changes |
|
| A cart is marked abandoned |
|
| 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.
Every delivery is an HTTPS POST with:
Body: { "type": "<event>", "payload": { … } }
Content-Type: application/json
Signature: lowercase hex HMAC-SHA256(rawBody, secret)
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.
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.
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.
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.
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.
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.
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.
Status
Meaning
CartGenie retries?
| Verified, parsed, handlers ran | No |
| Body is not valid JSON or not a valid envelope | Yes |
| Missing or invalid | Yes |
| Request was not a | Yes |
| Body exceeded | Yes |
| Wrong content type (Express only) | Yes |
| One of your handlers threw | Yes |
Express returns 500 with a "raw body required" message if the route is mounted without express.raw().
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:
onErrordoes not fire for401or400responses. Rejected deliveries return early, before any hook runs. This matters because a wrong signing secret — the most common setup mistake — produces noonErrorsignal at all. Alert on 401 responses from your webhook route at the load balancer, reverse proxy, or platform log level, not through this hook.
In order of likelihood:
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.
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.
A proxy or WAF is rewriting the body. Some gateways normalize or re-encode JSON payloads in transit, which changes the bytes.
You are testing with a hand-built request whose signature was computed over different bytes than were sent.
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.
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.
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.
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.
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.
// 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
Import
Export
Options
|
|
|
|
|
|
|
|
|
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.
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.