Webhooks
Verify outbound Carden notifications and keep merchant Stripe event verification in your own backend.
On this page
Two different event flows
| Flow | Verifier | Purpose |
|---|---|---|
| Stripe → your merchant backend | Your backend verifies Stripe's provider signature. | Observe later actual payment changes and submit normalized reports to Carden. |
| Carden → your webhook endpoint | Your endpoint verifies Carden's HMAC signature. | Receive notifications about Carden activity in the configured merchant context. |
Report Stripe webhook outcomes
- Read the raw Stripe request and verify it with your merchant-controlled Stripe webhook secret and Stripe's supported verification routine.
- Correlate the verified event to your stable attempt ID and existing Stripe object IDs.
- Retrieve current provider state when needed for ambiguous or out-of-order notifications.
- Persist a normalized payment report with a new event ID, actual occurrence time, cumulative amounts,
source: merchant_webhook, and providerEventId when available. - Deliver that report with your Carden
payments:writekey using a durable retry worker.
Send only the allowlisted payment report fields. Raw Stripe objects can contain client secrets or customer data and do not belong in the report payload.
Outbound Carden event types
| Type | Meaning |
|---|---|
payment.reported | A merchant payment observation was recorded. |
enrichment.prepared | Commercial payment fields were prepared. |
integration.synced | Integration sync activity completed successfully. |
integration.failed | An integration operation failed. |
team.updated | Merchant team configuration changed. |
workspace.updated | Workspace configuration changed. |
A saved destination's test-delivery control can emit webhook.test with a test message. It uses the same signature and event-identity headers. Handle it as a connectivity check rather than a business payment event.
The current event envelope contains id, type, createdAt, environment, and data. The body ID matches x-carden-event-id. data contains the affected entity's type and ID, a message, and event-specific safe details. Treat details as event-specific rather than casting every event to a payment report.
{
"id": "event_example",
"type": "payment.reported",
"createdAt": "2026-01-01T12:00:00.000Z",
"environment": "sandbox",
"data": {
"entityType": "payment_report",
"entityId": "report_example",
"message": "Merchant reported payment captured.",
"paymentReference": "pi_example",
"status": "captured",
"currency": "usd",
"amountMinor": 11300,
"evidence": "merchant_reported",
"applied": true
}
}Subscribe to the events your consumer needs using the webhook settings available in your workspace. Match the destination to the intended organization and environment. Inspect the actual event payload for details, handle additional fields defensively, and avoid coupling your handler to an assumed resource shape not described by its event contract.
These notifications describe Carden activity. In particular, payment.reported means reported activity, not network settlement, and enrichment.prepared does not certify processor acceptance.
Verify the Carden signature
x-carden-signature: t=<unix-seconds>,v1=<hex-hmac-sha256>
x-carden-event-id: <event-id>Compute HMAC-SHA256 with the webhook signing secret over the timestamp string, a period, and the unmodified raw request body: timestamp.body. Compare the result to v1 using a constant-time comparison. Verify before parsing JSON or performing side effects. The webhook signing secret is separate from your Carden API key.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyCardenSignature(
rawBody: Buffer,
signature: string | null,
secret: string,
nowSeconds = Math.floor(Date.now() / 1000),
): boolean {
if (!signature || !secret) return false;
const parts = signature.split(",").map((part) => part.trim());
const timestamps = parts.filter((part) => part.startsWith("t="));
const signatures = parts.filter((part) => part.startsWith("v1="));
if (timestamps.length !== 1 || signatures.length !== 1) return false;
const timestamp = timestamps[0].slice(2);
const supplied = signatures[0].slice(3);
if (!/^\d{1,12}$/.test(timestamp) || !/^[a-f0-9]{64}$/i.test(supplied)) return false;
// Example receiver policy: a five-minute tolerance with a synchronized clock.
if (Math.abs(nowSeconds - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(timestamp + ".")
.update(rawBody)
.digest();
const actual = Buffer.from(supplied, "hex");
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
// In a server handler, read bytes once before any JSON middleware:
// const rawBody = Buffer.from(await request.arrayBuffer());
// Verify using request.headers.get("x-carden-signature"), then parse JSON.The five-minute tolerance above is a receiver policy example, not a delivery service-level guarantee. Use a synchronized clock and coordinate any retry/replay behavior with the deployed sender. Never reconstruct signed JSON with JSON.stringify before verification; whitespace and byte changes alter the signature.
Process idempotently and recover
- Validate the signature, expected event type, and merchant context before processing.
- Deduplicate using the verified body ID and check that it equals
x-carden-event-id. The event-ID header alone is not proof of authenticity. - Persist the verified event or enqueue durable work before returning a successful acknowledgment.
- Keep business processing idempotent and tolerate repeated or out-of-order notifications.
- Monitor delivery failures and provide a reconciliation path that does not rely on webhook notifications as the only record.
An exact outbound retry schedule, replay window, or ordering guarantee is not specified here. Design a consumer that remains correct with duplicate delivery and recovery by reconciliation; inspect your deployment's delivery controls before relying on automatic retries.