Checkout widget
This is the mainnet API. Real funds on every network.
Take crypto payments with one button. Your customer pays in a modal with the asset they hold. You receive the asset you choose in one wallet, and a signed webhook tells you which account paid.
How it works
You set up once. After that, every payment runs the same way.
Set up once
- Choose what to receive. Sign in, accept the terms, and open Checkout widget. The first visit opens a guided setup. In Design your widget, save one asset and one wallet.
- Paste one snippet. In Add it to your site, select Create key and paste the snippet into your page.
- Choose how to be told. A signed webhook is the main path, and polling is the fallback. Send a test payment makes a simulated payment, and tells your webhook when one is saved.
After setup the page has three tabs: Payments, Settings and Developers.
Amounts, pay assets, your name, your colours, your logo and the wording (add funds, pay or donate) come from the dashboard. The page never sets where the money goes: your wallet address lives on our server only.
<script src="https://allways.venturalabs.ai/checkout.js"></script>
<button onclick="AllwaysCheckout.open({
key: 'alw_pub_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG',
account: '{{ user.id }}',
amount: '{{ order.total }}',
onSuccess: (p) => refreshBalance()
})">Add funds with crypto</button>Each payment
You never trace a transfer. Allways Access tells you which account paid. The flow shows the default account mode: your page passes account.
- Customer → Allways Accessstarts a payment account = user_877
- Allways Accesssaves the link payment ID ↔ user_877
- Allways Access → Customershows where to pay
- Customer → Allways networksends the pay asset for example SOL
- Allways network → Yousends USDC to your wallet
- Allways Access → Youpayment.delivered: user_877 paid 50 USDC signed webhook, or your poll
- Youcredit user_877 in your own system
AllwaysCheckout.open
AllwaysCheckout.open(options) opens the modal. AllwaysCheckout.close() closes it.
| Option | Value |
|---|---|
key | Required. Your publishable alw_pub_… key. It is safe in page source. It is the same key as the exchange widget. |
account | The customer's account ID in your system: 1 to 128 characters of A-Z a-z 0-9 . _ : @ + -. Required in site mode. A passed account wins in every mode. |
amount | Optional. The price in the asset you receive. It must be a positive decimal that the asset supports. When it is valid, the customer cannot change the receive amount. |
theme | auto (default) follows the customer's device. Set light or dark to match your site. |
onSuccess | Called once with { id, account } when the payment is delivered and the modal is open. Display only. |
onClose | Called when the modal closes, paid or not. |
onSuccess is display only and is never a credit signal. A customer can call it from the browser console, and it does not fire when the customer closes the modal before the payment completes. Use it to refresh what the page shows. Credit the account from the webhook or the poll.
amount is not signed. A customer can change any browser value. Create the order on your server, then call open({ account: orderId, amount }). Match the signed webhook account to the order. Credit the signed webhook amount, never the browser value. Mark a fixed-price order paid only when amount >= price.
Account modes
One setting in the dashboard decides how a payment names the customer. The name arrives as account in the webhook.
| Mode | Who names the account | account in the webhook |
|---|---|---|
site (default) | Your page passes account. The customer types nothing. | The value you passed |
ask | The customer types an email or username first. | The value they typed |
wallet | Nobody. The paying wallet is the account. | The paying address |
none | Nobody. | null |
account is not signed. A customer who changes it pays for another account, which costs you nothing. In site mode a page that passes no account shows a "not set up" state.
Webhook
Save an https URL in Developers. We POST one JSON body to it when a payment ends.
The event says where the customer's money ended.
| Event | The money is | amount | You |
|---|---|---|---|
payment.delivered | In your wallet | What to credit, in asset | Credit the customer |
payment.refunded | Back with the customer | "0" | Do nothing |
payment.failed | With neither of you | "0" | Do nothing. The customer may contact you with the payment ID. |
A payment the customer never sent has no event.
amount and settled
Credit amount. settled is what landed in your wallet, for your books.
They differ in one case: the party filling the payment missed its deadline, so its collateral paid in its place, with about 10% on top, in SOL or TAO.
| Customer pays | You sell for | Collateral lands | Event | amount | settled |
|---|---|---|---|---|---|
| ETH | 20 SOL | In your wallet | payment.delivered | "20" | {"asset":"sol","amount":"22"} |
| 20 SOL | ETH | In the customer's wallet | payment.refunded | "0" | null |
| Field | Meaning |
|---|---|
event | payment.delivered, payment.refunded or payment.failed |
id | The payment ID. The same ID arrives again on a retry or a resend. |
account | See account modes. null in none mode. |
asset | The asset you receive, an id from GET /v1/currencies |
amount | A decimal string in asset: what to credit. "0" unless the event is payment.delivered. |
settled | { asset, amount }: what landed in your wallet. null unless the event is payment.delivered, and null when the landed amount is not known yet. |
txHash | The transaction that paid your wallet. On payment.refunded it is the refund transaction. It can be null when collateral paid in TAO, and it is null on payment.failed. |
test | true only for a payment made with Send test payment |
A test payment carries "test": true, the ID test_…, the account test-customer, your first amount and txHash: null. No money moves and no credit is spent.
The webhook body can contain fields that are not listed here. Ignore fields that you do not use.
{
"event": "payment.delivered",
"id": "5d1f3c2e-8a1b-4c7d-9e0f-123456789abc",
"account": "user_877",
"asset": "solusdc",
"amount": "50",
"settled": { "asset": "solusdc", "amount": "50" },
"txHash": "4Fq8pT2mVxT4nZb8sLw3YcR6uE1jHf5dKa9oQvN2x",
"test": false
}Paid in the asset you receive
A shop that receives TAO or a subnet token can also be paid in that same asset, and only that asset: TAO pays a TAO shop, SN7 pays an SN7 shop. The modal lists it first. The customer sends straight to your wallet. No exchange runs and we never hold the money.
The modal asks for an exact amount: the amount picked plus a random tail under 0.001. The exact amount is how we match the transfer to the payment.
| Customer picks | Customer sends | amount | settled |
|---|---|---|---|
| 5 TAO | 5.000137 TAO | "5.000137" | {"asset":"tao","amount":"5.000137"} |
| Case | Result |
|---|---|
| Exact amount, within 24 hours of starting | payment.delivered |
| Within 1% of the exact amount, and it can belong to one payment only | Your dashboard shows Needs review. Accept it and payment.delivered is sent with the amount that arrived. Check the transaction first: we cannot know who sent it. |
| The same, with Accept automatically on in Settings | payment.delivered at once, with the amount that arrived |
| Any other amount, or later | It lands in your wallet. No event. We cannot return it. |
| Sent from an exchange | Matched when the exchange sends the exact amount. The sender does not matter. |
| The same amount sent twice | The first is matched. The second is not reported. |
| Sent in a batch, through a proxy, or as "transfer all" | Not matched |
| Another asset sent by hand, e.g. alpha to a TAO shop | Not matched. It lands in your wallet. No event. |
| You receive SOL, BTC, ETH or a token | Not offered yet |
A fixed-price shop checks amount >= price. Never check for an equal amount.
Verify the signature
Every POST carries x-allways-signature: t=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256 of <t>.<raw body> with your signing secret. Compute it over the raw bytes you received, before any JSON parsing. Reject a t more than 5 minutes from your clock.
The signing secret is on the Developers tab. Keep it on your server. Rotate replaces it at once.
This section has a Node sample and a Python sample. Select Show to open one.
Nodejs
const crypto = require('node:crypto');
// rawBody: the request body as a Buffer or string, exactly as received.
function verifyAllwaysWebhook(rawBody, header, secret, now = Date.now() / 1000) {
const parts = Object.fromEntries(
String(header).split(',').map((part) => part.split('=')),
);
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(now - t) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.`)
.update(rawBody)
.digest('hex');
const given = Buffer.from(String(parts.v1));
return (
given.length === expected.length &&
crypto.timingSafeEqual(given, Buffer.from(expected))
);
}Pythonpython
import hashlib
import hmac
import time
# raw_body: the request body as bytes, exactly as received.
def verify_allways_webhook(raw_body, header, secret, now=None):
parts = dict(part.split("=", 1) for part in header.split(",") if "=" in part)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs((time.time() if now is None else now) - t) > 300:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Retries
Answer with any 2xx within 5 seconds. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours, then the delivery stops and shows as failed. Resend on the dashboard sends it again at any time.
- The URL must be
httpsand resolve to a public address. Redirects are not followed. - Each retry and each resend is signed again with a new
t. - The same
idcan arrive more than once. Credit eachidonce.
Poll instead
Polling works without a webhook, and beside one. GET /v1/exchanges?updatedSince=<unix> returns every exchange updated at or after the cursor. It needs your API key.
| Exchange field | Webhook field |
|---|---|
id | id |
reference | account |
to | asset |
toAmount | amount, when status is finished |
status | finished is payment.delivered; refunded is payment.refunded; failed with reason claim_stale, dest_unpayable or asset_unavailable is payment.failed |
One exception: a refunded exchange whose refundAddress is your wallet was paid to you from collateral. Treat it as payment.delivered.
The cursor is inclusive, so the last exchange of one poll comes back in the next. Credit each id once. A payment made in the asset you receive is in the list too, with from equal to to. It can go from failed (deposit_lapsed) to finished when the transfer arrives late. The list also holds exchanges you created through the API. Test payments are never in it.
// Run on a timer. Persist `cursor` and the credited ids in your database.
async function pollPayments(cursor, alreadyCredited, credit) {
const res = await fetch(`https://api-allways.venturalabs.ai/v1/exchanges?updatedSince=${cursor}`, {
headers: { 'x-api-key': process.env.ALLWAYS_API_KEY },
});
if (!res.ok) return cursor;
for (const exchange of await res.json()) {
if (exchange.status === 'finished' && !(await alreadyCredited(exchange.id)))
await credit(exchange.reference, exchange.toAmount, exchange.id);
cursor = Math.max(cursor, exchange.updatedAt);
}
return cursor;
}Vendor rules
- Verify the signature and the timestamp before any other step.
- Credit only on
payment.delivered. - Credit each
idonce. - Credit from
amount. Never from a value the browser shows. - Ignore
"test": truein production code paths.
Cost and limits
- One credit per payment, taken from your balance when the customer starts it. It returns when the payment is refunded or fails. It is kept when the customer starts a payment and never sends.
- With no credits left the modal shows a paused state. Your dashboard header shows the balance.
- Up to 3 preset amounts, in the asset you receive. An amount under a pay asset's minimum cannot be paid with that asset; the dashboard warns when no asset can pay a preset.
- Each customer IP can start 3 payments per minute.
- A payment sent from an exchange account cannot be matched to the customer. The modal warns before the customer sends.
- The dashboard lists the latest 100 webhook deliveries.
Paused
Customers see a paused state when:
| Cause | Fix |
|---|---|
| Key missing, wrong, or rotated | Copy the snippet again |
| No checkout settings saved | Open Checkout widget and save Design your widget |
| No credits left | Top up |
Where it works
- Any page served over http or https. A file opened from disk cannot open the modal.
- If your site sends a Content-Security-Policy, add
https://allways.venturalabs.aitoscript-srcandframe-src. - Load
checkout.jsfromhttps://allways.venturalabs.ai. Do not copy, bundle or proxy it: the modal accepts messages from its own origin only. - On a phone the modal opens as a bottom sheet.
- Connect wallet runs in the modal. If a browser wallet does not answer there, the modal offers a small window on
https://allways.venturalabs.ai, where the wallet steps run and the modal waits. The window closes when the customer pays. Send it myself always works.
Rotate the key
Rotate the key from Developers or from Exchange widget. The old key stops working at once in every checkout widget and every exchange widget.
