Documentation
Accept crypto payments from any supported chain and settle in the asset you choose.
Overview
Tilll is a self-hosted payment server. Customers pay with the token they hold, on the chain they hold it. You receive the settlement asset you choose in your own NEAR Intents account, and you approve refunds with your own wallet. Tilll has no sign-up and charges no fee of its own. To see it work, try the live demo shop. It takes real payments, so keep the amounts small.
How a payment works
-
Server-side
Create an invoice
Your server sets the amount, using the merchant secret.
-
Client-side
The customer pays
They choose a network and a token in the checkout, then send the exact amount shown.
-
Server-side
Fulfil the order
Tilll sends a signed
invoice.paidwebhook to your server.
Key concepts
- Invoice
-
A request for a fixed amount, created by your server. It has a status of
open,paid,expiredorcanceled. - Payment attempt
- One try by a customer to pay an invoice with a chosen token. It has its own deposit address and exact amount.
- Settlement asset
-
The asset you receive, set by
SETTLEMENT_ASSET_ID. Invoice amounts, refunds and refund limits are all in this asset. The default is USDC on NEAR. - Settlement account
- The NEAR account whose NEAR Intents balance receives your payments and funds your refunds. It is the account of the wallet you connect to approve refunds.
- Wallet approval
- Tilll holds no key that can spend your balance. A refund is prepared by the server and signed by your wallet in the dashboard.
- Merchant secret
- Authorizes the merchant API and the dashboard. It stays on your server.
- Webhook secret
- Signs the events Tilll sends, so your server can verify their origin.
Quickstart
Run Tilll locally and create a test invoice. You need Node 22.12 or newer, pnpm and Docker.
1. Install and configure
Terminal
git clone https://github.com/space-rock/tilll tilll && cd tilll
pnpm install
pnpm tilll init
docker compose up --build
pnpm tilll init creates .env and generates your secrets. It doesn't print them,
and running it again changes nothing. Before you run the last command, set
SETTLEMENT_ACCOUNT_ID in .env to the NEAR account of the wallet you will use to
approve refunds. Tilll will not start without it. Tilll is ready when the log shows
Tilll is listening on http://localhost:8080.
2. Open the dashboard
Go to http://localhost:8080/dashboard and sign in with MERCHANT_SECRET from
.env.
3. Create a test invoice
Select Create invoice, then open the payment link. The hosted payment page for that invoice appears.
4. Verify your installation
Terminal
pnpm tilll check
pnpm dry-quote --from base:USDC --amount 5
pnpm demo
pnpm tilll check- Reads your configuration and tests each service it depends on. A ✗ names what to fix.
pnpm dry-quote- Requests a live quote without creating anything. It shows what a customer would send, the fees, and what you would receive.
pnpm demo- Starts a demo shop, a hosted payment page and the dashboard against a mock payment network, so you can pay and refund an invoice end to end.
Note. The quickstart moves no funds.
Accept a payment
Create an invoice on your server, open the checkout in your page, then fulfil the order when the payment is confirmed. The browser only ever handles the invoice id. The live demo shop works this way: the server prices the cart, the page opens the checkout, and the server confirms the payment.
Step 1 · Server-side
Create an invoice
Send POST /v1/invoices with the merchant secret. Take the amount from your own catalogue
and save the returned invoice id on the order.
Your server (Node)
// The total comes from YOUR catalogue, never from the browser.
const order = await db.orders.create({ items, total: "5.00" });
const response = await fetch(`${process.env.TILLL_URL}/v1/invoices`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MERCHANT_SECRET}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: order.total,
description: `Order ${order.id}`,
metadata: { orderId: order.id },
}),
});
const invoice = await response.json();
await db.orders.update(order.id, { invoiceId: invoice.id });
// Give the page invoice.id, or send the customer to invoice.payUrl.
Parameters
-
amountstring · Required -
The amount in settlement asset units, as a string such as
"5.00". -
descriptionstring · Optional - Shown to the customer. Up to 500 characters.
-
metadataobject · Optional - Your own key-value data, such as an order id. Up to 4 KB of JSON. It is returned in webhooks and is never shown to the customer.
-
expiresInMinutesinteger · Optional - How long the invoice stays open, from 1 to 10080 (seven days). Defaults to 60.
The response is the invoice object. It includes id and payUrl, the address of
its hosted payment page.
Step 2 · Client-side
Open the checkout
Load the script from your Tilll server and give a button the invoice id.
Embed the checkout
<script src="https://pay.example.com/checkout.js"></script>
<button data-tilll-invoice="inv_...">Pay with crypto</button>
data-tilll-invoice- Set on any element. Selecting it opens the checkout as a dialog. It works for elements added after the page loads.
data-tilll-inline- Set on an empty element. The checkout renders inside it.
Tilll.open(options)-
Opens the checkout from your own code.
optionstakesinvoiceIdand the optional callbacksonPaidandonClose.
Warning. Don't fulfil orders from onPaid. It runs
in the customer's browser and can be spoofed. Use it to update the page only.
Hosted payment page
To skip embedding, redirect the customer to payUrl. It shows the same checkout, served by
Tilll.
Content Security Policy
If your page sets a Content-Security-Policy, add your Tilll address to script-src for the
script, connect-src for its requests, and img-src for token and network
images. Without img-src the checkout shows letter badges instead of logos.
Step 3 · Server-side
Fulfil the order
When the customer's payment is confirmed, Tilll sends an invoice.paid webhook to
WEBHOOK_URL. Verify it and fulfil the order, as described in
Webhooks.
Webhooks
Use webhooks to learn when a payment completes. Tilll sends a signed JSON POST to
WEBHOOK_URL for every event.
The event object
An event
{ "id": "evt_...", "type": "invoice.paid", "createdAt": "...",
"data": { "invoice": { "id": "inv_...", "status": "paid", "amount": "5.00" }, "attempt": {} },
"proof": { "oneclickQuoteSignature": "...", "oneclickCorrelationId": "..." },
"proofVerified": true }
data-
The invoice and, for payment events, the payment attempt. The invoice includes your
metadata. proof- The signature that NEAR Intents' 1Click API placed on the quote behind the payment. Present on payment and refund events.
proofVerified-
Whether Tilll verified
proofagainst the 1Click public key.falseis expected on a mock or test network. On the live network, treat it as a reason to investigate.
Verify the signature
Each request carries the header Tilll-Signature: t=<unix seconds>,v1=<hex>.
v1 is the HMAC-SHA256, keyed with WEBHOOK_SECRET, of
"<t>.<raw body>". Compute it over the exact bytes you received, before parsing
the JSON, and reject a timestamp more than five minutes old.
Your server (Node)
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, header, rawBody) {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
return fresh && given.length === expected.length && timingSafeEqual(expected, given);
}
// rawBody is the request body exactly as received.
async function onWebhook(headers, rawBody) {
if (!verify(process.env.WEBHOOK_SECRET, headers["tilll-signature"] ?? "", rawBody)) return 400;
const event = JSON.parse(rawBody);
if (await db.events.seen(event.id)) return 200; // delivery is at least once
if (event.type === "invoice.paid") {
const invoice = event.data.invoice;
const order = await db.orders.findByInvoice(invoice.id);
if (order && Number(invoice.amount) === Number(order.total)) await fulfil(order);
}
await db.events.mark(event.id);
return 200;
}
Handle the event
Before you fulfil an order, confirm that:
- The signature is valid and the timestamp is fresh.
- The type is
invoice.paidand the invoice id matches the one saved on the order. - The invoice amount equals the order total.
-
You haven't processed this event before. Dedupe on the event
id, which is also sent in theTilll-Event-Idheader.
To check an invoice without a webhook, call GET /v1/invoices/:id from your server and confirm
that status is paid.
Retries and duplicates
Respond with any 2xx status to acknowledge an event. Otherwise Tilll retries after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then stops. The dashboard lists every delivery and lets you resend one. Delivery is at least once, so the same event can arrive more than once.
Event types
invoice.paid- A payment completed on an open invoice. Fulfil the order on this event.
invoice.expired- The invoice passed its expiry time with no payment on the way.
invoice.canceled- The invoice was canceled before it was paid.
payment.underpaid- A deposit arrived for less than the required amount.
payment.refunded_by_network- The swap could not complete, and the deposit went back to the customer.
payment.failed- The payment attempt failed.
payment.duplicate-
A payment arrived for an invoice that was already paid, expired or canceled. The money is in your
balance, and a refund for the full amount is queued for your approval.
data.causeisinvoice_already_paid,invoice_expiredorinvoice_canceled. refund.awaiting_approval- A refund was asked for, or queued for a duplicate or late payment, and waits for your wallet to approve it. Nothing has been sent.
refund.succeeded- A refund reached the customer.
refund.failed- A refund failed. The funds stay in your balance.
settings.updated-
A setting was changed in the dashboard.
data.changeslists each one assetting,fromandto. Secrets are never included. ping-
A test event, sent from the dashboard or by
POST /v1/webhooks/test.
Test webhooks locally
Run pnpm webhook-receiver with WEBHOOK_SECRET set, and point
WEBHOOK_URL at it. Then send a test event from the dashboard. The receiver prints each event
and whether its signature verified. Its source is a short verifier you can copy.
Refunds
Refund a payment in full or in part. Your wallet approves every refund in the dashboard.
Create a refund
Open a paid invoice in the dashboard and select Refund, or send
POST /v1/invoices/:id/refunds. To see what the customer would receive first, send
POST /v1/invoices/:id/refunds/preview. The refund is saved with the status
awaiting_approval and Tilll sends a refund.awaiting_approval event. Nothing is
quoted or sent until you approve it.
-
idempotencyKeystring · Required - Any string up to 200 characters, unique for each refund. Repeating a request with the same key returns the original refund.
-
amountstring · Optional - The amount to refund, in settlement asset units. Defaults to the remaining refundable amount.
Approve a refund
Connect your wallet with Connect wallet in the dashboard's top bar. It stays connected across pages and reloads. Select Approve in wallet on the refund. Tilll gets a fresh quote and a fresh intent from NEAR Intents, checks them, and your wallet signs what it checked.
POST /v1/refunds/:id/prepare-
Returns the
payloadto sign, thesignerIdthat has to sign it, when it expires, and a summary of who gets what. POST /v1/refunds/:id/submit-
Takes the wallet's
publicKeyandsignature. Tilll sends NEAR Intents the payload it kept, never one from the request, after it checks the signature and that the key is registered for the settlement account. POST /v1/refunds/:id/cancel- Cancels a refund that is still waiting. Its amount can be refunded again.
If the wallet will not sign, or NEAR Intents turns the intent down, the refund waits with the reason and
you can approve it again. A named account registers its wallet's key with NEAR Intents once. The dashboard
offers Register key the first time. NEAR Intents accepts ed25519,
secp256k1 and p256 keys. NEAR's post-quantum account keys (ML-DSA-65) are not
accepted yet, so an account that uses one cannot approve refunds.
Refund amounts
A refund is a new swap at the current price. The customer receives the value of the refund in the token they paid with, which can be a different number of tokens than they sent. The refund goes to the refund address they gave at checkout, on the chain they paid from.
Limits
Refunds are paid from your settlement balance, so the balance must cover them.
REFUND_MAX_PER_TX and REFUND_MAX_PER_DAY cap what the dashboard and API can
send. Both are in settlement asset units. You can change them in the dashboard without a restart.
Duplicate and late payments
NEAR Intents returns only the swaps that fail. A second payment on a paid invoice, or one that arrives
after the invoice expired, is a swap that worked, so the money is in your balance and has to be sent back.
Tilll queues a refund for the full amount to the address that payment came from, and sends
payment.duplicate and refund.awaiting_approval. It waits under
Needs your attention until you approve it. These refunds don't count toward the daily
limit.
Integration security
The checkout receives only an invoice id. The amount is fixed when your server creates the invoice, and the public checkout endpoints accept only a token and a refund address. A customer can't change what an invoice costs. To keep it that way, your integration has to meet four requirements.
Security requirements
- Set amounts on your server. Create each invoice there, from your own catalogue. Never expose an endpoint that takes the amount from the browser.
- Link each invoice to an order. Save the invoice id on the order. When a payment arrives, look the order up by that id in your own database, not by an id the browser sends, and check the amount against the order total.
-
Fulfil from webhooks, not from client callbacks. Fulfil an order only after a
verified
invoice.paidwebhook, or after your server reads the invoice and finds itpaid.onPaidruns in the browser and can be spoofed. -
Keep secrets on your server. Never put
MERCHANT_SECRETin page code. A site with no server can still sell: create each invoice in the dashboard and share its payment link.
Wallet approval
Tilll holds no key that can spend your balance. Reading .env or the database gives an
attacker no way to move your money. The dashboard refuses a wallet that is signed in as any account other
than the settlement account, and the server checks every signature before it sends anything.
Go-live checklist
Complete these steps before you accept real payments.
-
Serve Tilll over https. Tilll listens on
127.0.0.1. Put a reverse proxy with a certificate in front of it, and setPUBLIC_BASE_URLto the https address. -
Set
TRUST_PROXY=1. Otherwise every visitor shares the proxy's address, and the rate limits apply to all of them together. -
Restrict
ALLOWED_ORIGINSto your shop's origin, so only your site can call the checkout. -
Connect the settlement account's wallet. Select Connect wallet in the dashboard and check that it shows the account in
SETTLEMENT_ACCOUNT_ID. -
Point
WEBHOOK_URLat your server and send a test event from the dashboard. Confirm that your server verifies the signature. -
Make one small live payment and refund it. Approving the refund with your wallet also registers its key with NEAR Intents, if the account needs that. The runbook lists the steps.
Configuration
Tilll reads its settings from environment variables, usually in .env. The settlement account
and asset, the public URL, the webhook URL, the payment window, slippage and the refund limits are
starting values: you can change them on the dashboard's Settings page, and the change applies at once. A
value saved there is kept in the database and wins over .env until you reset it.
pnpm tilll init generates the secrets. You set SETTLEMENT_ACCOUNT_ID, and every
other setting has a default. A bad setting stops startup with a message that names the setting, never its
value.
-
PUBLIC_BASE_URLDefault: http://localhost:8080 -
Where customers reach Tilll, such as
https://pay.example.com. Used to build payment links. You can change it in the dashboard. -
ALLOWED_ORIGINSDefault: * -
Comma-separated origins allowed to call the checkout, such as
https://shop.example.com. No path. Set this in production. -
TRUST_PROXYDefault: false -
Set to
1when exactly one reverse proxy sits in front of Tilll. -
MERCHANT_SECRETGenerated by init -
Authorizes
/v1/*and the dashboard sign-in. Changing it ends every dashboard session. -
WEBHOOK_URLDefault: http://localhost:9000/hooks/tilll - The endpoint on your server that receives events.
-
WEBHOOK_SECRETGenerated by init - The key that signs every event.
-
SETTLEMENT_ACCOUNT_IDRequired - The NEAR account whose NEAR Intents balance receives your payments, and the account of the wallet you connect to approve refunds. Tilll will not start without it.
-
SETTLEMENT_ASSET_IDDefault: USDC on NEAR - The asset you are paid in: any asset id that the 1Click API lists. Tilll checks it at startup. Invoice amounts, refunds and refund limits are all in this asset.
-
ONECLICK_API_KEYOptional - A partner key for the 1Click API. Without one, 1Click may add a platform fee to quotes.
-
ATTEMPT_DEADLINE_MINUTESDefault: 30 - How long a customer has to send a payment. Bitcoin-like chains always get 120.
-
REFUND_MAX_PER_TXDefault: 50 - The largest single refund, in settlement asset units.
-
REFUND_MAX_PER_DAYDefault: 200 - The most you can refund in one UTC day, in settlement asset units.
-
EXTRA_CHAINSDefault: none -
More chains to offer at checkout, for example
ltc,doge. Limited to chains the 1Click API lists.
API reference
Use the merchant API to create invoices, issue refunds and read your balance from your server.
Authentication
Send the merchant secret as a bearer token on every /v1/* request:
Authorization: Bearer <MERCHANT_SECRET>. The /pay/* endpoints serve the
checkout. They need no secret and are rate limited for each visitor.
Conventions
Amounts are strings in display units, such as "5.00". Responses also include the exact
base-unit amounts. Errors use standard HTTP status codes and return a JSON body of the form
{ "error": { "code", "message" } }.
Endpoints
-
POST
/v1/invoicesCreate an invoice. Returns the invoice and its
payUrl. -
GET
/v1/invoicesList invoices, newest first. Filter with
statusandq, and page withlimitandcursor. -
GET
/v1/invoices/:idRetrieve an invoice with its payment attempts and refunds.
-
POST
/v1/invoices/:id/cancelCancel an open invoice that has no payment on the way.
-
POST
/v1/invoices/:id/refunds/previewPreview a refund: what the customer would receive, and where. Takes an optional
amount. -
POST
/v1/invoices/:id/refundsAsk for a refund. Takes
idempotencyKeyand an optionalamount. It waits for the wallet to approve it. -
GET
/v1/refundsList refunds, newest first. Filter with
status. The response also counts the refunds in each status. -
GET
/v1/refunds/:idRetrieve one refund.
-
POST
/v1/refunds/:id/prepareGet a fresh, checked intent for the wallet to sign.
-
POST
/v1/refunds/:id/submitSend the wallet's
publicKeyandsignature. -
POST
/v1/refunds/:id/cancelCancel a refund that is still waiting for approval.
-
GET
/v1/balanceRetrieve your settlement balance.
-
GET
/v1/webhooksList webhook deliveries.
-
POST
/v1/webhooks/testSend a
pingevent toWEBHOOK_URL.