Skip to content

SDK webhooks

Published release: SDK 0.1.3 is publicly available on npm. Package installation does not activate new backend features.

Do I need a Stripe webhook to know my customer paid?

Section titled “Do I need a Stripe webhook to know my customer paid?”

No — not for standard Kordiva hosted checkout. Kordiva processes signed Stripe notifications on its own servers and records the authoritative payment/order state. You do not configure a Stripe signing secret, Stripe webhook endpoint or Stripe account on your website.

0.1.3 adds signed Business Merchant lifecycle webhooks through a separate HMAC verifier. The existing one-time payment-confirmation flow below remains supported. For a custom Node.js, React or Next.js storefront, your authenticated server checks the existing LIVE Kordiva payment-confirmation API with @kordiva/sdk. Your front end calls your own server, never Kordiva with a secret key. A redirect to a success URL or an HTTP 200 from checkout creation does not prove a paid order.

  1. Call client.checkout.createHosted() on the server. Persist the returned sessionId, your authenticated buyer/cart identity and the stable idempotencyKey in durable application storage before returning only checkoutUrl to the browser. The small /api/checkout sample elsewhere returns just the URL: extend it with this persistence before real production adoption.
  2. After the buyer returns, your frontend calls a protected GET /api/payment-status?attempt=... endpoint on your own site. Authenticate the shopper; look up an attempt owned by that shopper, never trust a sessionId from the query string.
  3. On your server, call client.payments.confirm({ sessionId, idempotencyKey }) with the references from your stored record. Accept paid only when status === 'paid', isPaid === true, the expected identity matches and a paid Kordiva order is bound. Otherwise display pending, expired or a retryable unavailable state.
  4. Optionally poll your own backend about every 5 seconds for at most a minute while the shopper waits; then show “Payment processing — check again later.” Use a bounded, authenticated backend reconciliation task for abandoned tabs or slow Stripe events. Do not loop forever or create another charge on timeout.
  5. Trigger your own site’s receipt, access or delivery once, from its authorized server-side order workflow, only after verified payment and any required Individual fulfillment/entitlement checks. Payment status alone does not authorize an Individual download.

The confirmation API intentionally has a small state model:

| Confirmation | What your backend should do | | --- | --- | | paid + isPaid === true + orderId | Verify the stored attempt belongs to the authenticated buyer, then fulfill exactly once. | | pending | Do not fulfill and do not create another charge. Recheck the same stored attempt with bounded polling or backend reconciliation. | | expired | Treat that checkout attempt as closed. A new checkout requires a new authorized attempt. | | Request/API unavailable | Show a temporary status and reconcile later. Do not turn a network/API error into paid or a second checkout. |

SDK 0.1.3 does not expose a generic failed payment-confirmation state. A declined or failed card attempt stays in the hosted payment flow; merchant confirmation exposes only paid, pending or expired. Never infer success from the browser return path.

The following tested Node route uses the same stable actor:attempt identity as the checkout example. You must supply your own real customer authentication and durable findOwnedAttempt lookup; it intentionally provides neither a database nor a customer login service. For best expiry handling, persist the sessionId returned by createHosted. When only idempotencyKey is known, the API can report paid/pending but cannot always infer session expiry.

payment-status.ts
import type { Kordiva } from "@kordiva/sdk";
/** Example only: implement both callbacks using a durable cart-attempt store
* and your existing customer authentication. Never send a Store API key to a browser.
*/
export interface PaymentStatusRouteOptions {
client: Kordiva;
authorize: (request: Request) => Promise<string | null>;
findOwnedAttempt: (
actor: string, attempt: string,
) => Promise<{ sessionId?: string | null } | null>;
}
const ATTEMPT = /^[A-Za-z0-9_-]{8,64}$/;
const ACTOR = /^[A-Za-z0-9_-]{1,48}$/;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** Place behind GET /api/payment-status on a protected Node backend.
* The browser polls YOUR authenticated endpoint; Kordiva processes Stripe events.
* This endpoint is not a merchant Stripe webhook or an outgoing push notification.
*/
export function createPaymentStatusRoute({
client, authorize, findOwnedAttempt,
}: PaymentStatusRouteOptions) {
return async function GET(request: Request): Promise<Response> {
const headers = { "cache-control": "no-store" };
if (request.method !== "GET")
return Response.json({ error: "Method not allowed" }, { status: 405, headers });
if (client.environment !== "LIVE")
return Response.json({ error: "LIVE only" }, { status: 403, headers });
const attempt = new URL(request.url).searchParams.get("attempt");
if (!attempt || !ATTEMPT.test(attempt))
return Response.json({ error: "Invalid attempt" }, { status: 400, headers });
try {
const actor = await authorize(request);
if (!actor || !ACTOR.test(actor))
return Response.json({ error: "Unauthorized" }, { status: 401, headers });
const owned = await findOwnedAttempt(actor, attempt);
if (!owned)
return Response.json({ error: "Not found" }, { status: 404, headers });
const sessionId = owned.sessionId ?? null;
if (sessionId !== null && !UUID.test(sessionId))
throw Error("Invalid stored session identity");
// Matches the actor:attempt identity used by the published checkout example.
// Save createHosted().sessionId for accurate expiration results when possible.
const idempotencyKey = `${actor}:${attempt}`;
const payment = await client.payments.confirm({
...(sessionId ? { sessionId } : {}),
idempotencyKey,
});
if (
payment.idempotencyKey !== idempotencyKey ||
(sessionId && payment.sessionId !== sessionId)
) throw Error("Payment identity mismatch");
if (payment.status === "paid" && payment.isPaid && payment.orderId)
return Response.json({ status: "paid", isPaid: true }, { headers });
if (payment.status === "expired" && !payment.isPaid)
return Response.json({ status: "expired", isPaid: false }, { headers });
return Response.json({ status: "pending", isPaid: false }, { headers });
} catch {
// Fail closed: return neither an invented success nor leaked payment data.
return Response.json(
{ status: "unavailable", isPaid: false }, { status: 503, headers },
);
}
};
}

TEST is different: checkout.createTest() creates an isolated TEST link; payments.confirm() is LIVE-only and rejects TEST keys. Inspect completed TEST orders in the Kordiva TEST dashboard and the Stripe TEST webhook evidence there. Successful TEST payments do not create LIVE orders or payouts.

Kordiva owns Stripe event processing for Kordiva payments. Do not copy Kordiva Stripe secrets into a merchant integration or replace its existing webhook handlers. Confirm merchant payments through the supported server-side confirmation endpoint. A browser return URL is not a trusted event.

Business endpoint setup is in CMS → Settings → API keys → Developers / Webhooks. The SDK verifies events but exposes no endpoint-registration/key-management method; see setup and delivery. Build a bounded backend reconciliation job where required; do not poll from an untrusted browser with a secret key.

An existing, separately approved agentic order channel uses P-256 signed HTTP messages. If Kordiva has explicitly enrolled your integration and provisioned the trusted public key/key ID, the server-only @kordiva/sdk/webhooks export provides verifyOrderWebhook:

  1. Accept POST only and read the unchanged raw bytes with a host body-size limit.
  2. Pass body, request headers, the exact signed receiver url, trusted publicKeyPem and keyId. Behind a proxy, use your configured canonical receiver URL; never trust forwarded host headers blindly.
  3. Verify before acting. The verifier checks digest, signature coverage, key, route/query and a maximum five-minute validity window. It is not a Stripe signature verifier.
  4. Persist eventRef and apply orderRevision atomically before processing effects. Signatures do not provide persistent replay protection or guarantee ordered delivery. Acknowledge already processed events without duplicating work.
  5. Rotate pinned public keys through a trusted operational channel; never fetch a key from an event-supplied URL.

This helper grants no agent identity, merchant enrollment, order access or payment authority. Its compatibility is tested against Kordiva’s actual signer.

SDK · Checkout · Security · API

Recurring checkout and subscription reads → Signed Business events → Raw-body verification → Business refunds → API reference. Individual MoR follows its separate refund process.