Gyvar docs

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

EventWhen
checkout.successThe order is settled. Fulfil on this.
checkout.failedIt will not be. data.failure_code says why.

checkout.failed carries one of three codes:

failure_codeMeaning
expiredThe window passed with nothing received.
cancelledCancelled by you or by the buyer.
underpaid_declinedMoney 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.

checkout.success
{
  "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.

checkout.failed
{
  "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.

statusStore stateEvent
openpendingnone
paidprocessing, or completed if nothing shipscheckout.success
underpaidon-holdnone
escalateon-holdnone until resolved, then the event for the outcome
closedfailedcheckout.failed, underpaid_declined
expiredcancelledcheckout.failed, expired
cancelledcancelledcheckout.failed, cancelled
failedfailednone
refundedrefundednone

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.

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. Verify X-Gyvar-Signature, an HMAC-SHA256 over the timestamp and the raw body, keyed with your endpoint's whsec_ secret, not your API key. See Webhooks.
  • Send your order id as reference, not order_id.
  • A shortfall is short_by_minor on the order and settled.short_by on the webhook, not missing_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.

On this page