Pre-confirmation hook
A capacity check the partner controls — fires after the customer commits but BEFORE Stripe is charged. Use it for inventory-tight commerce: bookings, ticketing, classes, time-slotted appointments.
Configure it once
Section titled “Configure it once”await fetch( `https://api.swft.co.uk/v2/agencies/${ws}/merchants/${mid}/pre-confirmation`, { method: 'PUT', headers: { 'Authorization': `Bearer ${SWFT_PARTNER_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': uuid(), }, body: JSON.stringify({ url: 'https://api.unstack.co.uk/swft/capacity-check', fail_mode: 'closed', // default — 503 to client on partner timeout }), },)// → returns { url, secret, fail_mode } — SAVE the secret, never recoverable.Handle the call
Section titled “Handle the call”Swft POSTs to your url with Standard Webhooks-signed headers:
POST /swft/capacity-checkwebhook-id: msg_01HZ...webhook-timestamp: 1717000000webhook-signature: v1,<base64-hmac-sha256(secret, "${id}.${ts}.${body}")>content-type: application/json
{ "session_id": "sess_...", "merchant_id": "mer_...", "extensions": { "booking": { ... } }, "cart": { "items": [...], "total_pence": 2500, "currency": "GBP" }}Verify the signature, look up the class, and answer:
// Availablereturn Response.json({ available: true })
// Not available — pass a `reason` string; Swft echoes it in// `order.failed.data.reason` and to the API caller as// `error.meta.reason`.return Response.json({ available: false, reason: 'class_full' })Reference implementation (Next.js + Supabase)
Section titled “Reference implementation (Next.js + Supabase)”import { verifyStandardWebhook } from '@swft-checkout/js/webhooks'
export async function POST(req: Request) { const id = req.headers.get('webhook-id') ?? '' const timestamp = req.headers.get('webhook-timestamp') ?? '' const sig = req.headers.get('webhook-signature') ?? '' const body = await req.text()
if (!verifyStandardWebhook({ id, timestamp, body, signatureHeader: sig, candidateSecrets: [process.env.SWFT_PRECONFIRM_SECRET!] })) { return new Response('bad signature', { status: 401 }) }
const { session_id, extensions } = JSON.parse(body) const classId = extensions.booking?.class_schedule_id
const { data: cls } = await supabase .from('class_schedules') .select('booked_count, capacity') .eq('id', classId) .single()
if (!cls || cls.booked_count >= cls.capacity) { return Response.json({ available: false, reason: 'class_full' }) } return Response.json({ available: true })}Behaviour
Section titled “Behaviour”| Partner response | API behaviour |
|---|---|
{ available: true } | Stripe PI is created; checkout proceeds. |
{ available: false, reason } | 409 pre_confirmation_rejected; session marked failed; order.failed fires with reason. |
Timeout (>5s) / 5xx + fail_mode='closed' | 503 pre_confirmation_unavailable. |
Timeout / 5xx + fail_mode='open' | Proceed with payment; warning logged. |
Why before the PaymentIntent?
Section titled “Why before the PaymentIntent?”Doing the check before Stripe is charged means we never have to refund. Stripe idempotency holds the partner’s commitment until the page either confirms or fails — no double-charge risk on retry.