Skip to content

WONS Merchant API

Take payments from your own website. Post an amount, send your customer to the link you get back, and read the result.

Generating a client? The same reference as OpenAPI 3: /docs/api/openapi.json. A small working shop that uses every call on this page is in examples/storefront in the repository.

The whole thing, in one call

Open a checkout for an amount. You get a URL. Send your customer there.

curl -X POST https://merchant.wons.bt/api/v1/checkout/sessions \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042" \
  -d '{"amountMinor": 45000,
       "reference": "order-1042",
       "successUrl": "https://your-shop.bt/thanks"}'
{"session": {"id": "abc123", "status": "open",
  "url": "https://merchant.wons.bt/api/v1/../c/xxxx", "amountMinor": 45000,
  "currency": "BTN", "livemode": true, "expiresAt": "..."}}

No product to create, no customer record, no catalogue. An amount and somewhere to come back to.

Money is always a whole number of minor units

amountMinor: 45000 is Nu 450.00. There are no decimals anywhere in this API.

If we accepted 450.50 the rounding would be your problem and the bugs would be unfixable from here. Multiply by 100 and send a whole number.

Test first — and a test key really is safe

A key beginning wtest_ cannot move money. Its checkouts settle in a sandbox where you choose the outcome, so you can test a decline without needing one to happen.

A test key also sees only test data. It cannot read your live invoices, and a live key cannot read your test ones.

Every record a test key makes reports "livemode": false.

Knowing whether they paid

Ask us. Read the checkout back:

curl https://merchant.wons.bt/api/v1/checkout/sessions/SESSION_ID \
  -H "Authorization: Bearer YOUR_KEY"

paid is true once money has arrived, and invoice carries the numbered invoice it produced.

Or let us tell you

Add an endpoint under Webhooks and we will POST to it. We try up to 6 times, the last about 7 hours after the first, and you can send any delivery again by hand.

The customer landing on your successUrl is not proof of payment. Anyone can type that address. Confirm with one of the two methods above before you ship anything.

Checking a webhook is really from us

Every delivery carries X-Wons-Signature-V2: t=1788422400,v1=…. t is the moment this delivery was sent, in Unix seconds. v1 is HMAC-SHA256, as lowercase hex, over t, a dot, and the raw request body, keyed on your endpoint's signing secret.

import hashlib, hmac, re, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    # Parse strictly: exactly t and v1, each once, in the shapes we send.
    # Anything else is not our signature, so refuse it rather than guess.
    fields = {}
    for part in header.split(","):
        name, separator, value = part.partition("=")
        if not separator or name in fields or name not in ("t", "v1"):
            return False
        fields[name] = value
    t, v1 = fields.get("t", ""), fields.get("v1", "")
    if not re.fullmatch(r"[0-9]{1,12}", t) or not re.fullmatch(r"[0-9a-f]{64}", v1):
        return False
    if abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

Use the raw bytes. If you parse the JSON and build it again, the bytes change and the signature will never match — this is the mistake almost everyone makes first.

Check t, not created. Reject a delivery whose t is more than 300 seconds from your own clock, so a copied request cannot be replayed at you later. created is when the event happened and never changes: a delivery we retry an hour later, or one you replay by hand tomorrow, is old by that measure and perfectly genuine. Each attempt gets a fresh t and the same body.

Remember id. We deliver at least once, so the same event can arrive twice. Treat a repeat as already done.

Find your order with data.session.reference. For checkout.completed and checkout.expired it is the reference you sent when you opened the checkout, so you do not have to store our ids. Before you ship anything, read the checkout back as shown above and compare the amount, the currency and the reference to what you saved: a webhook tells you to look, and the read is what you act on.

Answer 2xx quickly and do the slow work afterwards. Anything else counts as a failure and is retried.

The older X-Wons-Signature header, an HMAC over the body alone, is still sent so integrations written against it keep working. It carries no time, so it cannot tell a fresh delivery from a copied one; new code should verify X-Wons-Signature-V2.

Events

  • checkout.completed
  • checkout.expired
  • invoice.paid
  • refund.completed

refund.completed tells you money has gone back to your customer. Its data is { "refund": { "id", "invoiceId", "amountMinor", "currency", "completedAt" } }: the amount is in minor units, as everywhere else, and the reason is never included. It is sent once per refund; use invoiceId to find your order.

Retrying safely

Send Idempotency-Key on every POST. If your connection drops and you retry, you get the first result back instead of a second charge.

  • Same key, same body → the original response, again.
  • Same key, different body → 409 idempotency_conflict. Use a fresh key per distinct call; do not reuse one across a loop.
  • Keys are remembered for 24 hours.
  • A key may be up to 200 characters. A longer one is refused with 422 invalid_request rather than shortened, so two long keys can never be mistaken for one another.

Limits and lifetimes

  • 120 requests a minute per key. You may set a lower figure on your own key; you cannot set a higher one.
  • A checkout stays payable for 7 minutes, then expires and leaves nothing behind.
  • An invoice's pay link and receipt keep working indefinitely — a customer can come back to them.

We cannot send response headers. There is no X-RateLimit-Remaining and no Retry-After — the platform this runs on has no way to set them. Everything you would read from a header is in the JSON body instead: a 429 carries retryAfterSeconds and limit.

When something is refused

Every refusal is JSON with a stable error code, a message written for you, and a docUrl.

{"error": "missing_scope",
 "message": "This key does not carry the `checkout.write` scope.",
 "docUrl": "/docs/api#missing_scope"}
CodeHTTPWhat to do
api_disabled404This platform is not offering an API. Ask the operator.
no_key401Send Authorization: Bearer … or X-API-Key.
invalid_key401The key is not one of ours. Check for a copied space.
key_revoked401Somebody revoked it. Issue a new one.
key_expired401It passed its expiry date. Issue a new one.
ip_not_allowed403The key names an address list your server is not on.
missing_scope403Add the scope to the key. The message names it.
workspace_inactive403The workspace is suspended. Nothing will work until it is not.
rate_limited429Wait retryAfterSeconds and try again.
idempotency_conflict409Reusing one key for a different body. Use a fresh key.
invalid_request422The message says exactly what is wrong with the body.
not_found404No such record for this key — check live vs test.
server_error500Ours. Retry with the same idempotency key.

Scopes

A key carries only what you tick. Give a storefront key the checkout scopes and nothing else — then a leaked key cannot read the invoices you typed by hand.

  • invoices.read
  • invoices.write
  • checkout.write
  • checkout.read
  • usage.read

Every endpoint

EndpointScopeWhat it does
POST /checkout/sessionscheckout.writeOpen a checkout, get a pay URL
GET /checkout/sessionscheckout.readList your checkouts
GET /checkout/sessions/:idcheckout.readDid they pay?
POST /invoicesinvoices.writeIssue a numbered invoice with a pay link
GET /invoicesinvoices.readList invoices
GET /invoices/:idinvoices.readOne invoice, with its lines
GET /usageusage.readWhat your keys have been doing
GET /healthanyIs the key good, and is it live or test

All paths are relative to https://merchant.wons.bt/api/v1.

Looking after your key

Rotate rather than revoke. Rotating issues a replacement and leaves the old key working for 72 hours, so you can deploy the new one without a race. Revoking stops the old key on its very next request — which will be a customer's checkout if the timing is unlucky.

Lock it to your server's address if you can. Exact addresses and ranges both work: 203.0.113.10 or 203.0.113.0/29. An empty list means the key works from anywhere.

Check what address we actually see before you rely on this — behind a proxy or NAT it is not the one your hosting panel shows you. Your key screen prints the address of whatever is talking to us.

Give a key only the scopes it needs. A storefront key with just the checkout scopes cannot read the invoices you typed by hand, so a leak costs you much less.

Checkout, or invoice?

Checkout for a shop. It expires, and an abandoned one leaves nothing behind — no document, no number used. The numbered invoice appears only when money arrives.

Invoice for billing somebody by name who needs the document itself — a tax invoice, a purchase order, an account customer.

If you are not sure, you want checkout.