The integration contract
MemberConsole is the canonical commerce authority for orders, payments, refunds, subscriptions, and the event history generated from those records. Your application may keep derived operational state, but it should never infer that a browser redirect, payment-processor callback, frontend state, or local database row is more authoritative than MemberConsole.
Webhook delivery is a fast signal. The Merchant API is the canonical read path. Your local database is a durable projection optimized for your own product.
Quick start: build the receiver in this order
- 1Create a server endpoint
Expose an HTTPS POST endpoint such as
/api/memberconsole/webhook. Do not put verification in React or another browser bundle. - 2Store secrets server-side
Use a merchant API key for canonical reads and a webhook signing secret for request verification.
- 3Read the raw body exactly once
Signature verification must use the exact bytes/text MemberConsole sent, before JSON is parsed and re-serialized.
- 4Verify timestamp, signature, and event ID
Reject stale or forged requests before touching business state.
- 5Claim the event ID atomically
Persist
event.idwith a unique constraint before provisioning, fulfillment, entitlement, email, analytics, or other side effects. - 6Acknowledge quickly
Return any 2xx after durable receipt. Queue expensive work in your own system.
- 7Reconcile when state matters
Before an irreversible action, retrieve the current order or other resource from the Merchant API when delivery order is uncertain.
MEMBERCONSOLE_API_KEY=mc_live_… MEMBERCONSOLE_WEBHOOK_SECRET=whsec_… # Never expose either value in browser code, public env variables, logs, or client bundles.
The server SDK expects a live merchant API key beginning with mc_live_. Webhook signing secrets begin with whsec_. Environment-variable names are your choice; the examples use MEMBERCONSOLE_API_KEY and MEMBERCONSOLE_WEBHOOK_SECRET.
Understand the event envelope
Every delivery carries a signed JSON event. Consumers should be tolerant of additive fields and should branch primarily on event.type, then inspect event.data.object.
{
"id": "evt_01JMCEXAMPLE",
"type": "order.confirmed",
"api_version": "2026-09-01",
"created_at": "2026-09-11T03:20:00.000Z",
"sequence": 1842,
"resource_version": 7,
"livemode": true,
"merchant": {
"id": "mer_example",
"slug": "example-store"
},
"data": {
"object": {
"order_number": "EX-K92L7PQ8",
"status": "confirmed",
"payment_status": "paid",
"fulfillment_status": "unfulfilled"
}
},
"links": {
"resource": "/v1/merchant/orders/EX-K92L7PQ8"
}
}idPublic immutable event ID. Use this as the webhook idempotency key.typeEvent name such as order.confirmed or refund.completed.api_versionContract version used to serialize the event. Current v1 documentation is 2026-09-01.created_atWhen MemberConsole created the immutable event.sequenceOptional event-stream sequence metadata. Do not replace idempotency with sequence assumptions.resource_versionOptional canonical resource version useful for conflict-aware operational updates.livemodeWhether the event belongs to live commerce traffic when present.merchantSigned merchant context containing the MemberConsole merchant ID and slug.recipientOptional receiving/partner context for explicitly shared workflows.data.objectImmutable resource snapshot associated with the event.links.resourceOptional canonical resource path that may be used as a reconciliation hint.Verify signatures before parsing business data
MemberConsole signs the timestamp plus the exact raw request body using HMAC SHA-256. The receiver should enforce a five-minute replay window by default, compare signatures in constant time, and verify the signed body event ID matches the MemberConsole-Event-Id header.
MemberConsole-Event-Id: evt_… MemberConsole-Timestamp: 1788990000 MemberConsole-Signature: v1=… MemberConsole-Version: 2026-09-01 signed_payload = timestamp + "." + raw_request_body expected = HMAC_SHA256(webhook_secret, signed_payload)
- Read
MemberConsole-Timestampas a 10-digit Unix timestamp. - Reject requests more than 300 seconds away from server time unless you intentionally configure another supported tolerance.
- Split
MemberConsole-Signatureon commas and evaluate everyv1=signature. Multiple signatures can appear during secret rotation. - Compute the expected HMAC over
timestamp + "." + rawBody. - Use constant-time comparison for the expected and received digest.
- Only parse JSON after signature validation, then ensure
MemberConsole-Event-Id === event.id.
import { MemberConsole } from "@memberconsole/server";
const memberconsole = new MemberConsole({
apiKey: process.env.MEMBERCONSOLE_API_KEY!,
});
export async function POST(request: Request) {
const rawBody = await request.text();
const event = await memberconsole.webhooks.verify(
rawBody,
request.headers,
process.env.MEMBERCONSOLE_WEBHOOK_SECRET!,
{ toleranceSeconds: 300 },
);
// Insert event.id into durable storage with a UNIQUE/PRIMARY KEY constraint.
const inserted = await persistWebhookEvent(event);
if (!inserted) return new Response(null, { status: 204 });
if (event.type === "order.confirmed") {
const orderNumber = String(event.data.object.order_number ?? "");
if (!orderNumber) throw new Error("Missing order_number");
// Current canonical state wins over assumptions based on delivery order.
const order = await memberconsole.orders.retrieve(orderNumber);
await enqueueProvisioningOrFulfillment(order, event.id);
}
return new Response(null, { status: 204 });
}import { createHmac, timingSafeEqual } from "node:crypto";
function safeHexEqual(a: string, b: string) {
const aa = Buffer.from(a, "utf8");
const bb = Buffer.from(b, "utf8");
return aa.length === bb.length && timingSafeEqual(aa, bb);
}
export function verifyMemberConsoleWebhook(
rawBody: string,
headers: Headers,
secret: string,
) {
const timestamp = (headers.get("MemberConsole-Timestamp") ?? "").trim();
const eventId = (headers.get("MemberConsole-Event-Id") ?? "").trim();
const signatureHeader = headers.get("MemberConsole-Signature") ?? "";
if (!/^\d{10}$/.test(timestamp)) throw new Error("invalid_timestamp");
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (age > 300) throw new Error("stale_timestamp");
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const signatures = signatureHeader
.split(",")
.map((value) => value.trim())
.filter((value) => value.startsWith("v1="))
.map((value) => value.slice(3));
if (!signatures.some((value) => safeHexEqual(value, expected))) throw new Error("invalid_signature");
const event = JSON.parse(rawBody);
if (!eventId || event.id !== eventId) throw new Error("event_id_mismatch");
return event;
}JSON.stringify(await request.json()) can change whitespace or key serialization and invalidate the cryptographic contract. Verify the exact raw body MemberConsole transmitted.
The SDK verifier exposes structured error codes that are useful for logs and tests:
missing_bodyinvalid_secretinvalid_timestampstale_timestampmissing_signatureinvalid_signatureinvalid_jsoninvalid_payloadinvalid_event_idinvalid_event_typeinvalid_api_versioninvalid_created_atinvalid_merchantinvalid_dataevent_id_mismatchDeduplicate before side effects
Webhook delivery is at-least-once. Duplicate delivery is normal and must not create a second license, shipment, account, robot reservation, subscription entitlement, refund action, or customer notification. The safe pattern is to claim the event ID transactionally before any effect leaves your database boundary.
create table if not exists memberconsole_webhook_events ( event_id text primary key, event_type text not null, api_version text not null, resource_version bigint null, payload jsonb not null, received_at timestamptz not null default now(), processed_at timestamptz null, processing_attempts integer not null default 0, last_error text null ); -- Claim delivery before side effects. insert into memberconsole_webhook_events (event_id, event_type, api_version, resource_version, payload) values ($1, $2, $3, $4, $5) on conflict (event_id) do nothing;
Unique insert conflicts because the event already exists → return 2xx → perform no repeated business effect.
Event is durably stored but downstream work fails → keep the row → record the error → retry your own worker without pretending the event was never received.
Use event.id for webhook deduplication. Use a separate stable Idempotency-Key for Merchant API mutations so retrying the same logical outbound write does not create duplicate work.
Current event catalog
These are the event types exposed by the current server SDK contract. Subscribe only to the categories your application needs, but make unknown-event handling forward-compatible by logging and acknowledging event types you do not yet act on.
Webhook
webhook.testCheckout
checkout.createdcheckout.completedcheckout.expiredcheckout.cancelledcheckout.payment_failedPayments
payment.createdpayment.pendingpayment.authorizedpayment.succeededpayment.failedpayment.cancelledpayment.refundedpayment.partially_refundedOrders
order.createdorder.confirmedorder.updatedorder.cancelledorder.fulfilledorder.shippedorder.deliveredorder.sharedorder.share_revokedRefunds
refund.requestedrefund.processingrefund.completedrefund.failedShipments
shipment.createdshipment.updatedshipment.deliveredSubscriptions
subscription.createdsubscription.updatedsubscription.cancelledChoose business effects from canonical state
Event snapshots are useful context, but the safest action depends on what your application is doing. A common implementation map looks like this:
webhook.testVerify delivery and observability only. Never trigger production fulfillment.checkout.*Update checkout/session UX projections. Do not treat checkout completion alone as the canonical paid-order entitlement.payment.*Synchronize payment status and reporting. When product access depends on the order, reconcile the order before granting or revoking entitlement.order.confirmedPrimary trigger for order-driven provisioning or fulfillment. Retrieve the canonical order when the action is irreversible or ordering could matter.order.updatedRefresh the local order projection and compare resource version/state before operational actions.order.cancelledStop future fulfillment or access according to product policy after confirming current canonical state.refund.*Track the refund lifecycle. Treat refund.completed or canonical refund state as completion, not merely a refund request.shipment.*Synchronize delivery progress, tracking, and customer-facing shipment status.subscription.*Update subscription entitlements from the current canonical subscription state.External applications should not overwrite MemberConsole payment totals, currency, processor status, refund completion, or other canonical financial authority. Operational write-back is intentionally scoped.
Reconcile after downtime or ordering uncertainty
Webhooks are not your only recovery path. Persist the opaque event cursor returned by the Merchant API and use GET /v1/merchant/events to catch up after an outage. When processing an event that controls an irreversible action, retrieve the affected resource directly when current state matters.
import { MemberConsole } from "@memberconsole/server";
const memberconsole = new MemberConsole({ apiKey: process.env.MEMBERCONSOLE_API_KEY! });
let cursor = await loadMemberConsoleCursor();
for (;;) {
const page = await memberconsole.events.list({ limit: 100, cursor });
for (const event of page.data) {
if (await claimEventId(event.id, event)) await processEvent(event);
}
if (!page.next_cursor) break;
cursor = page.next_cursor; // opaque: store and send back unchanged
await saveMemberConsoleCursor(cursor);
}- Store
next_cursorexactly as returned. - Never decode, increment, construct, or infer a cursor.
- Advance your saved cursor only after the page has been durably processed.
- Use
orders.changes()when you need a deterministic incremental order projection in addition to event history. - Use
sync.ack()when your integration records an explicit processing checkpoint.
Use the server SDK for the supported surfaces
The repository includes @memberconsole/server, a server-only SDK. The current package contract is version 0.2.0 and targets Node.js 20 or newer. If your integration environment has access to this package, prefer it for signature verification and Merchant API calls. Otherwise implement the documented HTTP and HMAC contracts exactly.
import { MemberConsole } from "@memberconsole/server";
const memberconsole = new MemberConsole({
apiKey: process.env.MEMBERCONSOLE_API_KEY!,
// Default base URL: https://api.memberconsole.com/v1/merchant
});orders.list(), orders.retrieve(), orders.changes()orders:read / sync:readpayments.list()payments:readrefunds.list(), refunds.create()refunds:read / refunds:writesubscriptions.list()subscriptions:readevents.list(), events.retrieve(), events.replay()events:read / webhooks:managewebhooks.verify(), webhooks.list(), webhooks.test()local verification / webhooks:managefulfillment.update(), shipments.create()orders:write / shipments:writeexternalReferences.set()sync:write / orders:writesync.ack(), sync.checkpoints()sync:write / sync:readpartnerConnections.*, orderShares.*, partnerOrders.*partners:read / partners:writeMutation helpers automatically generate an idempotency key when one is not supplied, but production integrations should pass a stable key when they may retry the exact same logical mutation. For conflict-sensitive operational updates, use the current resource version / If-Match control and reconcile on a 409 resource_version_conflict.
Framework recipes
The invariant is the same in every runtime: raw body → signature verification → unique event claim → quick 2xx → queued work.
import { MemberConsole } from "@memberconsole/server";
const memberconsole = new MemberConsole({ apiKey: process.env.MEMBERCONSOLE_API_KEY! });
export async function POST(request: Request) {
const rawBody = await request.text(); // read once, before JSON parsing
const event = await memberconsole.webhooks.verify(
rawBody,
request.headers,
process.env.MEMBERCONSOLE_WEBHOOK_SECRET!,
);
if (await claimEventId(event.id, event)) {
await queueMemberConsoleEvent(event);
}
return new Response(null, { status: 204 });
}import express from "express";
import { verifyMemberConsoleWebhook } from "@memberconsole/server";
const app = express();
// Important: raw body middleware must run before express.json() for this route.
app.post("/webhooks/memberconsole", express.raw({ type: "application/json" }), async (req, res) => {
const rawBody = req.body.toString("utf8");
const event = await verifyMemberConsoleWebhook(
rawBody,
req.headers,
process.env.MEMBERCONSOLE_WEBHOOK_SECRET!,
);
const inserted = await persistWebhookEventOnce(event);
if (inserted) await enqueueMemberConsoleWork(event);
res.sendStatus(204);
});
app.use(express.json());import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { verifyMemberConsoleWebhook } from "./memberconsole-webhook.ts";
Deno.serve(async (request) => {
const rawBody = await request.text();
const event = await verifyMemberConsoleWebhook(
rawBody,
request.headers,
Deno.env.get("MEMBERCONSOLE_WEBHOOK_SECRET")!,
);
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
const { data, error } = await supabase.from("memberconsole_webhook_events")
.insert({ event_id: event.id, event_type: event.type, payload: event })
.select("event_id")
.maybeSingle();
if (error?.code !== "23505" && error) throw error;
if (data) await enqueueMemberConsoleWork(event);
return new Response(null, { status: 204 });
});export default {
async fetch(request: Request, env: Env) {
const rawBody = await request.text();
const event = await verifyMemberConsoleWebhook(
rawBody,
request.headers,
env.MEMBERCONSOLE_WEBHOOK_SECRET,
);
// Durable Object, D1, or another transactional store is preferred for exact deduplication.
const firstReceipt = await claimEventId(event.id, event, env);
if (firstReceipt) await env.EVENT_QUEUE.send(event);
return new Response(null, { status: 204 });
},
};The cryptographic contract does not depend on a specific framework. If the SDK package is unavailable in an edge runtime, use Web Crypto or an equivalent HMAC-SHA256 implementation and preserve the raw request body exactly.
Retries, acknowledgement, and event ordering
MemberConsole uses durable event/outbox delivery and treats webhook delivery as at-least-once. Temporary failures and retryable HTTP responses can cause the same event to be delivered again. Your receiver should therefore be fast, durable, and duplicate-safe.
Return a 2xx after durable receipt. The response body is not canonical order state.
Never assume one HTTP request equals one business action.
When events appear out of order, fetch the current resource before an irreversible action.
Do not build fulfillment correctness around arrival order alone. The event may include sequence and resource_version metadata, but the canonical API is the recovery mechanism when the consumer needs certainty about the latest resource state.
Rotate signing secrets without downtime
During a signing-secret rotation, MemberConsole-Signature may carry multiple comma-separated v1= values. The correct verifier computes the expected digest with the secret it currently trusts and accepts the request if any v1 candidate matches.
- 1Start rotation
Generate the next signing secret through the merchant integration controls.
- 2Update the receiver
Store the new secret in server-side configuration. Never commit it to source control.
- 3Send a test event
Confirm the receiver accepts the rotated signature path and records the event exactly once.
- 4End overlap
Retire the previous secret after the configured overlap window.
Production-readiness tests
A receiver is not complete until it proves the failure modes. These tests should exist before normal production traffic depends on the integration.
If the endpoint can be replayed, duplicated, delayed, temporarily offline, and rotated without causing duplicate or incorrect business effects, the receiver has the right shape.
Copy this into an AI coding model
This compact brief is intentionally written as an implementation directive. Give it to a coding model together with the target repository and database access. The model should inspect existing schemas and reuse equivalent tables/fields instead of creating duplicates.
Implement MemberConsole as a server-to-server integration. Treat MemberConsole as the canonical commerce source of truth. Requirements: 1. Create an HTTPS POST webhook endpoint controlled by this application. 2. Read the exact raw request body before JSON parsing. 3. Verify MemberConsole-Timestamp is a 10-digit Unix timestamp and reject requests outside a 300-second replay window. 4. Compute HMAC-SHA256(secret, timestamp + "." + rawBody). 5. Parse MemberConsole-Signature as a comma-separated list and accept the request if any v1= value matches in constant time. 6. Verify MemberConsole-Event-Id exactly equals the signed JSON body event.id. 7. Persist event.id with a UNIQUE or PRIMARY KEY constraint before any provisioning, fulfillment, entitlement, email, or other side effect. 8. Return a 2xx response quickly after durable receipt. Move slow work to a queue/background worker owned by this application. 9. Handle duplicate deliveries as successful no-ops. 10. When ordering matters or an irreversible action depends on current state, retrieve the canonical resource from the MemberConsole Merchant API before acting. 11. Keep MEMBERCONSOLE_API_KEY and MEMBERCONSOLE_WEBHOOK_SECRET server-side only. 12. Never use browser redirects, query strings, localStorage, frontend callbacks, or a payment processor callback as the source of truth for a MemberConsole order. 13. Persist opaque next_cursor values exactly as returned. Never decode or synthesize cursors. 14. For write operations, use stable Idempotency-Key values and If-Match/resource-version controls where applicable. 15. Add tests for valid signature, altered body, stale timestamp, duplicate event, invalid event ID, secret rotation, receiver retry, and reconciliation after downtime. Prefer @memberconsole/server when it is available to the project. Otherwise implement the verification contract above exactly.
Developer overview, webhook reliability guide, the target repository, and the exact MemberConsole merchant/store identifier it is integrating. The model should not invent event fields when a canonical field already exists.
Verify first. Persist once. Reconcile when it matters.
That three-part rule is the core of a reliable MemberConsole integration. It keeps external applications synchronized without letting webhook delivery behavior become your financial source of truth.
