Checkout and OTP
The order form is a normal HTML form. It posts to an Astro action, the action calls the server, and the browser lands on a thank-you page. The server prices the order; the browser only describes the choice.
The theme starter has all of it working: the product
form and the basket checkout (src/components/checkout/OrderForm.astro,
src/scripts/order-form.ts and its parts in src/scripts/checkout/), the
action (src/actions/index.ts) and the thank-you redirect. This page explains
every piece: the order, the basket, OTP, bot protection and conversions.
Code
Keep the flow post/redirect/get. A direct render of the success page behind a POST makes a refresh re-attempt the order.
The action
Astro's defineAction is the thin framework wrapper; the SDK owns the input
whitelist and the submission.
Code
executePlaceOrder enforces one rule: an order is either a basket (items) or
one product. Anything else is a shape error. It also forwards the shopper's
User-Agent, IP and Referer so attribution is recorded for the visitor, not
for your Worker.
result.data is the accepted order:
Code
Those totals are authoritative only after the server accepts the order.
The form fields
placeOrderInput is a whitelist. A field it does not name is stripped before
the action runs, so use its names exactly.
| Field | Notes |
|---|---|
customerName | Required, 2–100 characters |
phone | Algerian mobile; accepted in local or international form |
wilayaId | Required, 1–58 |
communeId | Required |
address | Up to 300 characters |
deliveryType | home or stop_desk |
notes | Up to 500 characters |
email | Optional unless the store's policy requires it |
couponCode | Up to 64 characters |
otpToken | Set after phone verification |
turnstileToken | Set by the bot-protection widget |
checkoutKey | Added by bindCheckoutKey; places the checkout's order once |
customFieldResponses | JSON array for merchant-defined fields |
items | JSON array for a basket order |
productId, productName, pricePerUnit, quantity, variantId, offerId | The single-product form; pricePerUnit is required by the shape check |
variantSelections | JSON array for per-unit variant choices |
landingPageSlug | Attribution for a landing-page order |
fbc, fbp | Meta click and browser cookies |
For a basket, serialize the basket into one hidden input:
Code
For custom fields, one JSON field keeps the form independent of the action's schema:
Code
pricePerUnit is display-only, but a single-product order must include it:
executePlaceOrder rejects a form that has neither items nor a product with
a price. The server re-prices the line anyway, so it is never the charge.
Show the server's refusal
The action can refuse for a field, a business rule, or a coupon.
- Shape — neither a basket nor a product.
BAD_REQUEST; this is a bug in the form, not a shopper error. - Validation — the server's
domainErrornames the field and the rule. Render the error beside that field. - Coupon —
couponRefusalis returned without throwing. The code box shows the reason and the shopper can change it without re-entering the order. - Business — an unavailable product, a closed store, an undeliverable destination. Keep the form filled and show the reason.
Do not replace these with a generic “something went wrong”. A shopper who chose a sold-out size needs to know which choice to change.
One checkout, one order
A shopper who double-clicks, refreshes the page their order was posted from, or retries after a dropped connection sends the same checkout again. Bind a key to the form once and the server places that checkout's order once:
Code
Every submission then carries checkoutKey. The same key again answers with
the order already placed, exactly as the first time: no second order, no second
stock deduction, no second notification. The key stays the same while the
shopper changes nothing and is drawn anew once they change any field, so a
different order is never answered with the first. It is drawn in the browser at
the first submission, never rendered into the page, because a storefront page is
cached for every shopper. For another phone the key fails with
CHECKOUT_KEY_IN_USE.
Thank-you and tracking
After success, redirect to thankYouHref(order, { locale, routing }):
/thank-you?order=<number>&total=<total>&oid=<orderId>, in the shopper's
language, with nothing about the shopper in it. The thank-you page reads it
back with readThankYou(Astro.url) ({ orderNumber, orderId, total }, each
empty or null when missing), and an editor previews the page by building
the same address with a sample order. In an editable theme the thank-you page is a
Studio surface like any other page type, with the thank-you-main section in
the starter.
The page asks the server which pixel and event this order belongs to:
Code
tracking is null when the lookup fails. In that case fire nothing:
Code
The event is already resolved. If the merchant's conversion fires later in the
funnel, event is null; do not fall back to Purchase.
Code
Use trackSingle, not track: the sale belongs to one pixel, the one the
server's Conversions API mirrors. eventID: orderId is what deduplicates the
browser event against the server event. A different ID double-counts.
When tracking is unavailable, skip the browser event rather than guessing a pixel. The server-side event still exists.
WhatsApp OTP
OTP is optional per store (config.otpEnabled). When enabled, the checkout
phone must be verified before the order is accepted.
The browser flow is a state machine from the SDK:
Code
The same-origin routes are created by the SDK:
Code
Code
What the machine owns
| State | What to render |
|---|---|
idle | Nothing; waiting for a phone |
sending | Disable the send button |
code_sent | Code field, resend countdown, attempt count |
verifying | Disable verify and submit |
verified | Put otpToken in the hidden input |
bypassed | Provider unavailable; put bypassToken in the same input |
error | The reason; resend may be available |
send() starts a 60-second cooldown (or the server's longer rate-limit
window). verify() accepts six digits, counts attempts, and moves to a terminal
state when the code expires. setPhone() resets the flow when the phone
changes; the token must never outlive the number it was issued for.
Code
When the phone verifies, submit the form once. The proof travels as otpToken,
not as a second field.
bypassed is not an error to hide: the server failed open on purpose, and the
order must still be attested. It carries a signed bypass token and the reason.
Without JavaScript
The form still posts. The server answers OTP_VERIFICATION_REQUIRED if the
phone is not verified, and the page shows that error. The OTP step is an
enhancement, not the only path.
Turnstile
When the store enables bot protection, the widget writes a token. The action
expects it as turnstileToken; if the widget's field is named differently, copy
it into that field before submit.
A missing or expired Turnstile token is a validation refusal. Keep the rest of the form; do not clear it.
Abandoned checkout
An optional tracker records a checkout the shopper left, once a name and a
valid mobile are present. In a browser, createBrowserAbandonedCheckoutTracker
wires it to the page — the checkout's id in sessionStorage, the timers, the
Meta _fbc and _fbp cookies, a keepalive POST to your route, and
sendBeacon when the shopper leaves:
Code
schedule waits three seconds after typing stops before sending; update only
sends again after the first record exists. A page that blocks storage still
captures, and nothing the tracker does can fail the order.
createAbandonedCheckoutTracker(ports) is the same tracker with every port
passed in, for a host that is not a browser page.
The thank-you page marks the checkout converted, so the merchant no longer sees the order it became as abandoned:
Code
It is fire-and-forget: it reads the id the tracker kept
(ABANDONED_CHECKOUT_SESSION_KEY), uses it once, and never throws or holds the
thank-you page.
Track an order
A shopper has no account: the order's number and the phone it was placed with
prove an order is theirs. The platform's page for it is trackOrder
(/track-order); a link with ?order=<number> — from the thank-you page, say —
fills the number in.
lookupOrder makes that page a plain form posted to an Astro Action: no script,
and the answer, rendered from the same POST, is never cached.
Code
lookup.status is found (with lookup.order), invalid-number (not an order
number — isOrderNumber decides, and the server is not asked), invalid-phone,
not-found (a wrong number or a wrong phone, never saying which),
rate-limited (with retryAfterSeconds) or failed. Return what was asked
beside it, so a refused form keeps what the shopper typed.
A found order carries its stage in the shopper's words, its lines, the total
to pay, the delivery type and, once the carrier has the parcel, the tracking
number. SHOPPER_ORDER_JOURNEY is the path from placed to delivered a
progress bar shows; returned and cancelled end an order off it, and a stage
a newer server adds reads your general words.
A checkout checklist
-
The form posts to
placeOrder; the basket is one JSONitemsfield. -
pricePerUnitis present for display and never treated as the charge. -
Custom fields are serialized by field
id. -
OTP is rendered only when
config.otpEnabledis true. -
The OTP token is in
otpToken; a changed phone resets it. -
Turnstile's token is in
turnstileToken. -
bindCheckoutKeyis bound to the checkout form. -
The page that rendered the form redirects to the thank-you page on success (
Astro.getActionResult); the middleware never redirects. -
The thank-you page fires only the pixel and event
orders.trackingreturns. -
eventIDis the order id. -
The abandoned session is marked converted after success.
-
The thank-you page calls
convertAbandonedCheckout. -
The track-order page lives at
storefrontPath("trackOrder")and words everylookupOrderoutcome.