Checkout
Take payment for an order, and know when it is settled.
You have something to be paid for - a cart, an invoice, a booking. You create an order for what the buyer owes, send them to the page it returns, and a webhook tells you when it is settled.
Nothing about the payment touches your server. There is no wallet to hold, no chain to watch, no address to reconcile and no key that can move money out.
The webhook is the truth, not the redirect
Fulfil on checkout.success, never on the buyer arriving at your success_url. A buyer
who pays and closes the tab never comes back; a buyer who comes back has not necessarily
paid. The return page is a courtesy.
The shape of it
1. Create the order
POST /v1/checkout/orders with your own reference, the total, and where to send the buyer
afterwards. You get back a url.
2. Send the buyer to url
That page is ours. The buyer picks an asset, sees an address or an invoice, and pays. You are not involved.
3. Fulfil on checkout.success
Your webhook endpoint gets a signed event carrying your reference. Match it to your order
and ship.
Your first order
Sandbox and live are the same host. The key you sign with decides which one you are in, so going live is a key swap and nothing else.
curl -X POST https://api.gyvar.com/v1/checkout/orders \
-H 'Content-Type: application/json' \
-H "X-Gyvar-Key: $GYVAR_KEY_ID" \
-H "X-Gyvar-Timestamp: $TS" \
-H "X-Gyvar-Nonce: $NONCE" \
-H "X-Gyvar-Signature: $SIG" \
-d '{
"reference": "order_1001",
"description": "Order #1001 at Kente & Co",
"amount": "125.50",
"amount_minor": 12550,
"currency": "GHS",
"success_url": "https://shop.example.com/checkout/thanks",
"metadata": { "order_id": "1001" }
}'See Signing for how those four headers are built. A shop key needs
read + receive and never money - see Access.
Before your first order: register your return address
success_url and cancel_url have to be on an origin you registered in the dashboard,
under Settings → Payment links. Until you do, an order carrying either is refused with
return_origin_not_registered.
The list fails closed: a business that has registered nothing has said nothing about where its buyers may be sent, and reading silence as "anywhere" is the hole this closes. Somebody holding your key could otherwise mint a real, Gyvar-branded page carrying your name with a "return to the shop" button pointing anywhere they liked.
Sending neither URL is fine and is a working checkout - the page shows the paid state and stops.
reference is yours, and it is the idempotency key
Send your own order id. Two things follow.
A retry is safe. A repeat create for the same reference and the same money returns the
same order, with a 200 instead of a 201. So a create that timed out, or a buyer who
double-tapped, cannot open a second order or charge twice.
A changed price is refused. A repeat for a different amount or currency is a 409
reference_conflict, because that is a new price against an order the buyer may already be
looking at. Cancel the open one first, or send a new reference.
A reference is unique per business only while the order can still take money. Once it is terminal the value is free again.
Send both amount forms
amount is the decimal string your platform renders. amount_minor is your reading of it
in minor units. Send both, and we recompute the minor units from amount at our scale
for the currency and refuse the order if the two disagree.
That check is there for one specific way to lose a lot of money:
The zero-decimal trap
UGX has no minor unit. 31,875 UGX is 31875 minor units - but a shop left on the default
two decimal places renders the same total as "31875.00" and computes 3187500. That is
a hundredfold overcharge, and nothing downstream could detect it. XOF, XAF and RWF are
the same trap.
Either field alone is accepted. amount alone is in fact the safer integration, because
the scale is then entirely ours.
Events
| Event | When |
|---|---|
checkout.success | The order is settled. Fulfil on this. |
checkout.failed | It will not be. data.failure_code says why. |
checkout.failed carries one of three codes:
failure_code | Meaning |
|---|---|
expired | The window passed with nothing received. |
cancelled | Cancelled by you or by the buyer. |
underpaid_declined | Money arrived, fell short, and you declined it in the dashboard. |
`underpaid_declined` does not mean unpaid
The money was credited to you and stays credited. The event means do not fulfil, not "the buyer was not charged". If you are not fulfilling, refund it from the dashboard.
The body
data is the order, in the same shape GET /v1/checkout/orders/{id} returns. Match on
reference.
{
"event": "checkout.success",
"event_id": "evt_c51ce02fd4ec4841948fa0c6442dedd1",
"created_at": "2026-10-05T11:23:32Z",
"data": {
"id": "c3e930eb-f6cb-488e-b9c1-1fa93442e567",
"reference": "order_1001",
"kind": "checkout",
"status": "paid",
"amount": "150.00",
"amount_minor": "15000",
"currency": "GHS",
"currency_decimals": 2,
"description": "Order #1001 at Kente & Co",
"settled": {
"asset": "USDC",
"chain": "base",
"network": "onchain",
"quoted": "12.490000",
"quoted_minor": "12490000",
"received": "12.490000",
"received_minor": "12490000"
},
"metadata": { "order_id": "1001" },
"expires_at": "2026-10-05T12:18:13Z",
"paid_at": "2026-10-05T11:23:32Z"
}
}checkout.failed has the same data, plus failure_code and failure_message. It has no
paid_at, and no settled unless the buyer picked an asset.
{
"event": "checkout.failed",
"event_id": "evt_9a0e4c1b7d3f4e2a8b6c5d4e3f2a1b0c",
"created_at": "2026-10-05T11:30:00Z",
"data": {
"id": "66d907e9-247b-4c8a-ab84-28f8259e2083",
"reference": "order_1002",
"kind": "checkout",
"status": "expired",
"amount": "10.00",
"amount_minor": "1000",
"currency": "GHS",
"currency_decimals": 2,
"failure_code": "expired",
"failure_message": "The order expired before it was paid",
"expires_at": "2026-10-05T11:30:00Z"
}
}An order can emit both, in either order. One that expired and was then paid in full
publishes checkout.failed and later checkout.success with late: true. Treat success
as final whichever you saw first - and check stock before shipping, because you may already
have written that one off.
Nothing fires while an order is underpaid or escalate. Both are waiting on a person,
and telling your shop "failed" for an order somebody is about to accept would cancel it
underneath them. When an escalated order is resolved, you get the event for how it was
resolved: checkout.success if paid, checkout.failed if closed or cancelled.
See Webhooks for signature verification, deduplication and retries.
Mapping to store order states
The order's status is one of nine values. Map them to your store like this.
status | Store state | Event |
|---|---|---|
open | pending | none |
paid | processing, or completed if nothing ships | checkout.success |
underpaid | on-hold | none |
escalate | on-hold | none until resolved, then the event for the outcome |
closed | failed | checkout.failed, underpaid_declined |
expired | cancelled | checkout.failed, expired |
cancelled | cancelled | checkout.failed, cancelled |
failed | failed | none |
refunded | refunded | none |
A cancelled store order can still move to processing: checkout.success with late: true
wins over an earlier checkout.failed. For statuses with no event, read the order.
Reconciling
Use GET /v1/checkout/orders/reference/{reference}. You know your own reference; you may
never have stored the id we minted, and after a timeout on create you certainly did not.
It returns the live attempt if there is one, and otherwise the most recent attempt of any status.
Two things that are not obvious
Expiry stops the page, not the crediting. Money arriving after an order expired is
still yours. That is what late exists to tell you.
Cancelling does not stop us watching. A cancelled order whose buyer pays anyway is still credited and still linked to that order; it surfaces in the dashboard for a person to look at. Nobody loses money to a status change made in a browser.
Payment links
A payment link is a page you publish once and send to anyone - no integration, no key. They are created in the dashboard rather than through the API, because a link is a public page carrying our domain and your name, and minting one stays a human act.
Orders started from a link arrive in the same list and emit the same events, with
kind: "payment_link" instead of "checkout". Filter on kind if your integration should
only see its own.
Porting from OpenNode
Coming from OpenNode
- Requests are signed with your Ed25519 key over a canonical string (method, path, body hash, timestamp, nonce, key id). See Signing.
- Webhooks carry no
hashed_order. VerifyX-Gyvar-Signature, an HMAC-SHA256 over the timestamp and the raw body, keyed with your endpoint'swhsec_secret, not your API key. See Webhooks. - Send your order id as
reference, notorder_id. - A shortfall is
short_by_minoron the order andsettled.short_byon the webhook, notmissing_amt.
Already on WooCommerce?
The Gyvar Payments plugin does all of this for you - the same three steps, plus the block checkout, order notes and refund guidance. See WordPress and WooCommerce.