E-Commerce Checkout
Guide
A practical, end-to-end recipe for plugging CHING into a storefront where the SKUs, stock, and shipping rules live in your own database. You collect the buyer's details and cart on your site, then hand the payment off to a CHING-hosted page. Card data, 3DS, receipts, and VAT all happen on CHING's side.
Mental model
Three things happen on every order, in this order, server-side:
- Create (or look up) a CHING customer for the buyer.
- Create a checkout session referencing that customer plus the cart.
- Redirect the buyer to the session URL and fulfil the order from the resulting webhook.
customer field on a checkout session is required and must be a pre-existing cus_* id. CHING does not auto-create a customer from an email or anything else - if you skip step 1 the API returns 400 Bad Request.Before you start
- Grab a
ck_test_key from Developers in the dashboard. Keep it on the server; never ship it to the browser. - Decide where buyers get redirected after checkout - both a
success_urland acancel_urlare required. - Have a webhook endpoint ready (or stand one up alongside this work). All fulfilment is driven by webhooks, not the success page.
1. Create a CHING customer for the buyer
Build the customer from whatever fields your checkout form already collects. Only name is required; email and phone are optional but worth sending so receipts and the billing portal can reach the buyer later.
curl -X POST https://api.ching.co.il/ching/v1/customers \
-H "Authorization: Bearer ck_test_..." \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"email": "email@example.com",
"phone": "+9725000000000"
}'The response contains a stable cus_* id - persist it on your own user (or order) row so repeat customers reuse the same CHING customer:
{
"success": true,
"data": {
"id": "cus_V8ltq1pK_MWH",
"object": "customer",
"name": "John Doe",
"email": "email@example.com",
"phone": "+972500000000",
"livemode": false,
"created": "2026-05-10T08:42:11.000Z"
}
}Idempotency-Key header. If you have a logged-in shopper with a stored cus_*, reuse it instead of creating a new customer per order. For guest checkout, key the lookup by email in your own database before falling back to POST /v1/customers.2. Build the cart line items
Translate your cart into the line_items shape. Each line is independent - shipping, taxes-on-top, and discounts go in as their own lines. Three rules:
- Amounts are in agorot (1/100 of a shekel). ₪129.00 is
12900. Never floats. amount_agorotis signed. Use a negative value to render a discount line.- The cart total (
sum(amount_agorot * quantity)) must be>= 0. CHING rejects carts that net to a negative balance.
const lineItems = cart.lines.map((line) => {
const product = catalog.get(line.slug);
return {
name: product.name,
description: product.tagline,
image_url: product.heroImage, // must be https://
amount_agorot: product.priceAgorot,
quantity: line.quantity,
};
});
if (cart.shippingAgorot > 0) {
lineItems.push({
name: "Shipping",
amount_agorot: cart.shippingAgorot,
quantity: 1,
});
}
if (cart.discountAgorot > 0) {
lineItems.push({
name: `Discount: ${cart.couponCode}`,
amount_agorot: -cart.discountAgorot,
quantity: 1,
});
}The hosted page renders each line with its name, description, optional thumbnail, and a per-line subtotal. Names are limited to 255 characters and descriptions to 500. image_url must be HTTPS.
3. Create the checkout session
For an ad-hoc cart, send line_items - not a price id. The API picks the cart branch automatically when line_items is present; there is no mode field, and sending both price and line_items is rejected.
curl -X POST https://api.ching.co.il/ching/v1/checkout_sessions \
-H "Authorization: Bearer ck_test_..." \
-H "Content-Type: application/json" \
-d '{
"customer": "cus_V8ltq1pK_MWH",
"line_items": [
{ "name": "Nintendo Switch 2", "amount_agorot": 149900, "quantity": 1,
"description": "Console + dock + Joy-Con pair",
"image_url": "https://shop.example.com/img/switch2.png" },
{ "name": "Xbox Elite Controller", "amount_agorot": 59900, "quantity": 1 },
{ "name": "Shipping", "amount_agorot": 1500, "quantity": 1 },
{ "name": "Discount: WELCOME10", "amount_agorot": -1500, "quantity": 1 }
],
"success_url": "https://shop.example.com/checkout/success?cs={CHECKOUT_SESSION_ID}",
"cancel_url": "https://shop.example.com/checkout/cancel",
"create_document": true
}'Response:
{
"success": true,
"data": {
"id": "co_aB3xPQrLm9Tk",
"url": "https://secured.ching.co.il/checkout/co_aB3xPQrLm9Tk",
"expires_at": "2026-05-10T09:12:11.000Z"
}
}Sessions live for 30 minutes. Always create a fresh one per checkout attempt - never cache or share the URL across buyers. The {CHECKOUT_SESSION_ID} placeholder in success_url is substituted with the session id when the buyer is redirected back.
create_document: true (the default) tells CHING to issue an Israeli tax invoice receipt the moment the cart is paid. Set it to false only if you generate invoices in another system.4. Redirect the buyer to the hosted page
From your /api/checkout route, return a 303 See Other (or a JSON body with { url } that the front-end navigates to):
export async function POST(req: Request) {
const cart = await req.json();
const customer = await ching("/customers", {
method: "POST",
body: { name: cart.fullName, email: cart.email, phone: cart.phone },
});
const session = await ching("/checkout_sessions", {
method: "POST",
body: {
customer: customer.id,
line_items: buildLineItems(cart),
success_url: `${origin}/checkout/success?cs={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/checkout/cancel`,
create_document: true,
},
});
return Response.redirect(session.url, 303);
}On the hosted page CHING renders your line items, collects the card (or Bit / Apple Pay / Google Pay where available), runs 3DS, charges, and issues the invoice before redirecting the buyer to success_url.
5. Fulfil from the webhook, not the success page
Buyers close tabs, lose connectivity, or come back via deep links. Trust the webhook, not the redirect. For cart checkouts subscribe to charge.succeeded - the payload includes the original line_items and a checkout_session id you can correlate back to the order:
switch (event.type) {
case "charge.succeeded": {
if (!event.data.checkout_session) break; // not from a hosted checkout
if (!event.data.line_items) break; // not a cart - was a price-id session
await markOrderPaid({
checkoutSessionId: event.data.checkout_session,
customerId: event.data.customer,
chargeId: event.data.id,
totalAgorot: event.data.amount,
lineItems: event.data.line_items,
});
await sendConfirmationEmail(event.data.customer, event.data.id);
break;
}
case "charge.failed": {
await releaseCartReservation(event.data.checkout_session);
break;
}
}Ching-Signature header. Recompute HMAC-SHA256(raw_body, endpoint_secret) and compare with a timing-safe equality. See Webhook Basics for the full handler.6. The success page
Treat success_urlas a UX landing only - say "thanks, check your email". If you must show order details immediately, look the order up by the {CHECKOUT_SESSION_ID}query parameter and render "processing..." until your webhook handler has marked the order paid. Never grant the customer their goods purely on the redirect.
Testing locally
- Use a
ck_test_key. Charges always succeed and the resulting documents are clearly marked test. - Tunnel your local webhook URL with
ngrok http 3000(orcloudflared tunnel) and paste the public HTTPS URL into Developers → Webhooks in the dashboard. - Subscribe to
["*"]in test mode so you see every event during development.
Common pitfalls
| Symptom | Cause |
|---|---|
| 400 Bad Request when creating the checkout session | Missing the customer field, or sending customer_email / mode (no such fields). Create a customer first and pass its cus_* id as customer. |
| Cart total looks 100x off on the hosted page | Sent shekels instead of agorot. Multiply by 100 at the API boundary; divide by 100 at the UI boundary. |
| image_url rejected with 400 | Hosted images must be https://. http://, data:, or relative URLs are refused. |
| Order paid but never fulfilled | You're granting goods on the success_url instead of charge.succeeded. Move fulfilment into the webhook handler. |
| Signature verification fails intermittently | Body was JSON-parsed before HMAC. Capture req.body as raw bytes (express.raw, Request.bytes()) and verify against those exact bytes. |
| Cart total invalid after a discount | sum(amount_agorot * quantity) went negative. Cap discounts so the cart sum stays >= 0. |
Authorize now, capture later (J4J5)
For stores where the final amount isn't known at checkout time - variable-weight goods, made-to-order, or anything that needs a manual stock check - create the session with capture_method: "manual". CHING authorizes the card (Grow J5 hold) but doesn't move any money until you explicitly call capture or cancel.
// 1. Create the session as manual
const session = await ching.post('/v1/checkout_sessions', {
customer: 'cus_...',
line_items: [/* ... */],
capture_method: 'manual',
success_url: '...',
cancel_url: '...',
});
// 2. Customer pays on the hosted page. You receive charge.authorized
// (NOT charge.succeeded) and have 7 days to act.
// 3a. Stock confirmed -> capture (full or partial). Difference is
// auto-released to the customer's card.
await ching.post(`/v1/charges/${chargeId}/capture`, {
amount: 12300, // omit for full capture
});
// 3b. Order canceled -> release the hold.
await ching.post(`/v1/charges/${chargeId}/cancel`, {
cancellation_reason: 'requested_by_customer',
});capture_method: "manual" is only valid for one-time prices and carts paid with a new card. Recurring prices are rejected; saved cards and Apple Pay / Bit / Google Pay are forced to automatic. The hosted checkout hides those options automatically for manual sessions.Where to next
- Adding subscriptions on top? Switch to
price-mode checkout - see Use Hosted Checkout. - Need a self-service order history? Billing Portal Sessions give buyers a hosted page to view past invoices and saved cards.
- Going live: rotate your env to a
ck_live_key after finishing Grow KYC and linking a business identity.