Quickstart#
In five minutes you will create a session for a 45,000 IQD basket, pay it on the hosted page with the simulated gateway, verify it from your server and receive a signed webhook. Everything here runs in test mode; swap the key for a live one and the same calls take real money.
Before you start#
You need, from Povito:
- a test secret key —
povito_ck_test_…— shown once when it is issued; keep it on your server only; - your
success_urlandcancel_urlhosts registered on your merchant account (return_host_not_allowedotherwise); - the
testpayment method enabled on your test-mode merchant. Check withGET /v1/payment_methods: the row with"code": "test"must say"enabled": true.
curl https://api.checkout.povito.com/v1/payment_methods \
-H "Authorization: Bearer $POVITO_SECRET_KEY"Money
Amounts are integers in minor units of the currency. The Iraqi dinar has no minor unit, so 45000 means 45,000 IQD. For USD the unit is the cent. Floats and strings are rejected with validation_error.
1. Create a session#
POST /v1/checkout/sessions needs an Idempotency-Key header (a UUID is the convention) so a retried request cannot create two sessions. The line_items must reconcile to amount: charges, shipping and fees add, discounts subtract — 42,000 + 5,000 − 2,000 = 45,000.
curl -X POST https://api.checkout.povito.com/v1/checkout/sessions \
-H "Authorization: Bearer $POVITO_SECRET_KEY" \
-H "Idempotency-Key: 7d9d2a1e-3b0f-4c7e-9a1d-2f6b8c1e5a90" \
-H "Content-Type: application/json" \
-d '{
"reference_id": "POV-1041",
"amount": 45000,
"currency": "IQD",
"line_items": [
{ "label": "Basket", "amount": 42000, "type": "charge" },
{ "label": "Delivery — Hi-Express", "amount": 5000, "type": "shipping" },
{ "label": "Coupon RAMADAN", "amount": 2000, "type": "discount" }
],
"payment_method_types": ["test"],
"customer": { "phone": "+9647500000000", "name": "Ali M. Ismail" },
"locale": "ar",
"success_url": "https://your-store.example/checkout/return",
"cancel_url": "https://your-store.example/cart",
"metadata": { "cart_id": "cart_01J8Z3K9Q4M2XA7B" }
}'import { randomUUID } from "node:crypto"
const API = "https://api.checkout.povito.com/v1"
const headers = {
Authorization: `Bearer ${process.env.POVITO_SECRET_KEY}`,
"Content-Type": "application/json",
}
const res = await fetch(`${API}/checkout/sessions`, {
method: "POST",
headers: { ...headers, "Idempotency-Key": randomUUID() },
body: JSON.stringify({
reference_id: "POV-1041",
amount: 45000, // 45,000 IQD — the dinar has no minor unit
currency: "IQD",
line_items: [
{ label: "Basket", amount: 42000, type: "charge" },
{ label: "Delivery — Hi-Express", amount: 5000, type: "shipping" },
{ label: "Coupon RAMADAN", amount: 2000, type: "discount" },
],
payment_method_types: ["test"],
customer: { phone: "+9647500000000", name: "Ali M. Ismail" },
locale: "ar",
success_url: "https://your-store.example/checkout/return",
cancel_url: "https://your-store.example/cart",
metadata: { cart_id: "cart_01J8Z3K9Q4M2XA7B" },
}),
})
if (res.status !== 201 && res.status !== 200) throw new Error(await res.text())
const session = await res.json()
// Store session.id against your order BEFORE redirecting; a lost webhook is then recoverable.
await orders.attachCheckoutSession("POV-1041", session.id)The response is the full session. 201 means it was created; 200 means an open session already existed for this reference_id in this mode with the same price and was returned unchanged. (If the price changed — the cart was edited — the open session is expired and a new one is created with 201.)
{
"id": "cs_test_7KQ4M2XA9BC3DEFGH1JK",
"object": "checkout.session",
"code": "7KQ4M2XA9BC3DEFGH1JKMNPQRS",
"url": "https://checkout.povito.com/c/7KQ4M2XA9BC3DEFGH1JKMNPQRS",
"livemode": false,
"status": "open",
"payment_status": "unpaid",
"reference_id": "POV-1041",
"amount": 45000,
"currency": "IQD",
"line_items": [
{ "label": "Basket", "amount": 42000, "quantity": 1, "type": "charge" },
{ "label": "Delivery — Hi-Express", "amount": 5000, "quantity": 1, "type": "shipping" },
{ "label": "Coupon RAMADAN", "amount": 2000, "quantity": 1, "type": "discount" }
],
"payment_method_types": ["test"],
"customer": { "id": null, "phone": "+9647500000000", "name": "Ali M. Ismail", "address": null },
"collect": { "phone": "required", "name": "required", "shipping_address": "none" },
"presentment": null,
"payment": null,
"refunded_amount": 0,
"locale": "ar",
"success_url": "https://your-store.example/checkout/return",
"cancel_url": "https://your-store.example/cart",
"expires_at": "2026-09-10T13:00:00.000Z",
"completed_at": null,
"metadata": { "cart_id": "cart_01J8Z3K9Q4M2XA7B" },
"created_at": "2026-09-10T12:00:00.000Z",
"updated_at": "2026-09-10T12:00:00.000Z"
}Sessions expire 60 minutes after creation by default (expires_at accepts 5 minutes to 24 hours). Save id against your order before you redirect.
2. Send the shopper to url#
Redirect the browser (302) or open the link in an in-app browser. On the page the shopper enters their phone and an SMS code, confirms name and address as your collect settings require, picks a method and pays.
In test mode use the test phone +9647500000000 with code 000000 — no SMS is sent. The test method shows a simulated vendor page with Succeed / Fail / Cancel / Take 90 s buttons instead of a real bank. Press Succeed.
When the session completes, the page sends the shopper to:
GET https://your-store.example/checkout/return?session_id=cs_test_7KQ4M2XA9BC3DEFGH1JK&reference_id=POV-1041No status is ever put in that URL, so there is nothing to spoof. If the shopper cancels, they are sent to cancel_url with nothing appended.
3. Verify with GET#
On your return page read session_id from the query, then ask Povito what actually happened. This is the verify call; the answer is authoritative.
curl https://api.checkout.povito.com/v1/checkout/sessions/cs_test_7KQ4M2XA9BC3DEFGH1JK \
-H "Authorization: Bearer $POVITO_SECRET_KEY"const res = await fetch(`${API}/checkout/sessions/${encodeURIComponent(sessionId)}`, { headers })
const session = await res.json()
if (session.reference_id !== order.reference_id) throw new Error("session does not belong to this order")
if (session.status === "completed" && session.payment_status === "paid") {
await fulfil(order, session.payment) // captured_amount, captured_currency, method, gateway_reference
} else if (session.payment_status === "requires_offline_collection") {
await scheduleCashCollection(order) // cash on delivery — no money has moved
} else {
// open / processing: still paying. expired / canceled: offer to try again.
}{
"id": "cs_test_7KQ4M2XA9BC3DEFGH1JK",
"object": "checkout.session",
"code": "7KQ4M2XA9BC3DEFGH1JKMNPQRS",
"url": "https://checkout.povito.com/c/7KQ4M2XA9BC3DEFGH1JKMNPQRS",
"livemode": false,
"status": "completed",
"payment_status": "paid",
"reference_id": "POV-1041",
"amount": 45000,
"currency": "IQD",
"line_items": [
{ "label": "Basket", "amount": 42000, "quantity": 1, "type": "charge" },
{ "label": "Delivery — Hi-Express", "amount": 5000, "quantity": 1, "type": "shipping" },
{ "label": "Coupon RAMADAN", "amount": 2000, "quantity": 1, "type": "discount" }
],
"payment_method_types": ["test"],
"customer": { "id": null, "phone": "+9647500000000", "name": "Ali M. Ismail", "address": null },
"collect": { "phone": "required", "name": "required", "shipping_address": "none" },
"presentment": null,
"payment": {
"attempt_id": "pa_test_3M8XQ2K7A9BC4DEFGH1J",
"method": "test",
"gateway": "test",
"gateway_reference": "test_pa_test_3M8XQ2K7A9BC4DEFGH1J",
"captured_amount": 45000,
"captured_currency": "IQD",
"presented_amount": 45000,
"presented_currency": "IQD",
"fx_rate": null,
"captured_at": "2026-09-10T12:04:31.000Z",
"instrument": null
},
"refunded_amount": 0,
"locale": "ar",
"success_url": "https://your-store.example/checkout/return",
"cancel_url": "https://your-store.example/cart",
"expires_at": "2026-09-10T13:00:00.000Z",
"completed_at": "2026-09-10T12:04:31.000Z",
"metadata": { "cart_id": "cart_01J8Z3K9Q4M2XA7B" },
"created_at": "2026-09-10T12:00:00.000Z",
"updated_at": "2026-09-10T12:04:31.000Z"
}payment is null until an attempt succeeds. captured_amount is what the vendor reported taking, in the currency it took it — Povito refused to mark the attempt paid unless that equalled what was presented.
4. Handle the webhook#
Shoppers close tabs. Register an HTTPS endpoint once and Povito will POST checkout.session.completed (and the other event types) to it. The signing secret is returned only in this response.
curl -X POST https://api.checkout.povito.com/v1/webhook_endpoints \
-H "Authorization: Bearer $POVITO_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://your-store.example/webhooks/povito", "events": ["*"], "mode": "test" }'{
"id": "we_2K7A9BC4DEFGH1J3M8XQ",
"object": "webhook_endpoint",
"url": "https://your-store.example/webhooks/povito",
"events": ["*"],
"mode": "test",
"status": "enabled",
"disabled_reason": null,
"description": null,
"created_at": "2026-09-10T11:58:00.000Z",
"secret": "whsec_9Xp2fQ7Lw4vB1nR8sT6uY3zA0cD5eF2gH7jK4mN1pQ8"
}Each delivery carries Povito-Signature: t=<unix>,v1=<hex> where v1 = HMAC-SHA256(secret, "<t>.<raw body>"). Verify the raw bytes before parsing JSON, reject anything older than five minutes, then treat the event as a prompt to run step 3.
import { createHmac, timingSafeEqual } from "node:crypto"
import express from "express"
const app = express()
function verify(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
const parts = (header ?? "").split(",").map((p) => p.trim().split("="))
const t = Number(parts.find((p) => p[0] === "t")?.[1])
if (!Number.isFinite(t) || Math.abs(now - t) > 300) return false
const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"), "hex")
return parts
.filter((p) => p[0] === "v1")
.some((p) => {
const given = Buffer.from(p[1] ?? "", "hex")
return given.length === expected.length && timingSafeEqual(given, expected)
})
}
app.post("/webhooks/povito", express.raw({ type: "application/json" }), async (req, res) => {
if (!verify(req.body, req.header("povito-signature"), process.env.POVITO_WEBHOOK_SECRET)) return res.status(401).end()
const event = JSON.parse(req.body.toString("utf8"))
if (await events.alreadySeen(event.id)) return res.status(200).end() // deliveries retry; be idempotent
if (event.type === "checkout.session.completed") {
const r = await fetch(`${API}/checkout/sessions/${event.data.object.id}`, { headers })
const session = await r.json() // the truth, not the payload
if (session.payment_status === "paid") await fulfil(session.reference_id, session.payment)
if (session.payment_status === "requires_offline_collection") await scheduleCashCollection(session.reference_id)
}
await events.markSeen(event.id)
res.status(200).end() // anything outside 2xx is retried: 1 m, 5 m, 30 m, 2 h, 6 h, 24 h, 24 h
}){
"id": "evt_test_4DEFGH1J3M8XQ2K7A9BC",
"object": "event",
"type": "checkout.session.completed",
"livemode": false,
"created_at": "2026-09-10T12:04:31.000Z",
"data": {
"object": {
"id": "cs_test_7KQ4M2XA9BC3DEFGH1JK",
"object": "checkout.session",
"status": "completed",
"payment_status": "paid",
"reference_id": "POV-1041",
"amount": 45000,
"currency": "IQD",
"payment": { "attempt_id": "pa_test_3M8XQ2K7A9BC4DEFGH1J", "method": "test", "captured_amount": 45000, "captured_currency": "IQD", "…": "…" },
"…": "the full session, as GET returns it"
}
}
}The full verifier in PHP, Python and Dart, the retry schedule and redelivery are in the webhooks guide.
Going live#
- Ask Povito for your live keys and enable your real methods (
zaincash,fib,fib_card,card,cashondelivery, …) on the live-mode merchant — see payment methods. - Register a second webhook endpoint with
"mode": "live"; live events go only to live endpoints. - Replace
payment_method_types: ["test"]with your live methods, or omit the field to offer every enabled method. - Keep everything else. The session shape, the return URL contract and the verify call are identical.
Prefer a typed client?
@povito/checkout-node wraps these calls, generates idempotency keys, retries safely and verifies webhook signatures.