Skip to content
tilll GitHub

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

  1. Server-side

    Create an invoice

    Your server sets the amount, using the merchant secret.

  2. Client-side

    The customer pays

    They choose a network and a token in the checkout, then send the exact amount shown.

  3. Server-side

    Fulfil the order

    Tilll sends a signed invoice.paid webhook to your server.

Key concepts

Invoice
A request for a fixed amount, created by your server. It has a status of open, paid, expired or canceled.
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

amount string · Required
The amount in settlement asset units, as a string such as "5.00".
description string · Optional
Shown to the customer. Up to 500 characters.
metadata object · 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.
expiresInMinutes integer · 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. options takes invoiceId and the optional callbacks onPaid and onClose.

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 proof against the 1Click public key. false is 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.paid and 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 the Tilll-Event-Id header.

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.cause is invoice_already_paid, invoice_expired or invoice_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.changes lists each one as setting, from and to. 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.

idempotencyKey string · Required
Any string up to 200 characters, unique for each refund. Repeating a request with the same key returns the original refund.
amount string · 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 payload to sign, the signerId that has to sign it, when it expires, and a summary of who gets what.
POST /v1/refunds/:id/submit
Takes the wallet's publicKey and signature. 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

  1. Set amounts on your server. Create each invoice there, from your own catalogue. Never expose an endpoint that takes the amount from the browser.
  2. 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.
  3. Fulfil from webhooks, not from client callbacks. Fulfil an order only after a verified invoice.paid webhook, or after your server reads the invoice and finds it paid. onPaid runs in the browser and can be spoofed.
  4. Keep secrets on your server. Never put MERCHANT_SECRET in 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 set PUBLIC_BASE_URL to 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_ORIGINS to 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_URL at 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_URL Default: 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_ORIGINS Default: *
Comma-separated origins allowed to call the checkout, such as https://shop.example.com. No path. Set this in production.
TRUST_PROXY Default: false
Set to 1 when exactly one reverse proxy sits in front of Tilll.
MERCHANT_SECRET Generated by init
Authorizes /v1/* and the dashboard sign-in. Changing it ends every dashboard session.
WEBHOOK_URL Default: http://localhost:9000/hooks/tilll
The endpoint on your server that receives events.
WEBHOOK_SECRET Generated by init
The key that signs every event.
SETTLEMENT_ACCOUNT_ID Required
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_ID Default: 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_KEY Optional
A partner key for the 1Click API. Without one, 1Click may add a platform fee to quotes.
ATTEMPT_DEADLINE_MINUTES Default: 30
How long a customer has to send a payment. Bitcoin-like chains always get 120.
REFUND_MAX_PER_TX Default: 50
The largest single refund, in settlement asset units.
REFUND_MAX_PER_DAY Default: 200
The most you can refund in one UTC day, in settlement asset units.
EXTRA_CHAINS Default: none
More chains to offer at checkout, for example ltc,doge. Limited to chains the 1Click API lists.

The README lists every setting.

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/invoices

    Create an invoice. Returns the invoice and its payUrl.

  • GET /v1/invoices

    List invoices, newest first. Filter with status and q, and page with limit and cursor.

  • GET /v1/invoices/:id

    Retrieve an invoice with its payment attempts and refunds.

  • POST /v1/invoices/:id/cancel

    Cancel an open invoice that has no payment on the way.

  • POST /v1/invoices/:id/refunds/preview

    Preview a refund: what the customer would receive, and where. Takes an optional amount.

  • POST /v1/invoices/:id/refunds

    Ask for a refund. Takes idempotencyKey and an optional amount. It waits for the wallet to approve it.

  • GET /v1/refunds

    List refunds, newest first. Filter with status. The response also counts the refunds in each status.

  • GET /v1/refunds/:id

    Retrieve one refund.

  • POST /v1/refunds/:id/prepare

    Get a fresh, checked intent for the wallet to sign.

  • POST /v1/refunds/:id/submit

    Send the wallet's publicKey and signature.

  • POST /v1/refunds/:id/cancel

    Cancel a refund that is still waiting for approval.

  • GET /v1/balance

    Retrieve your settlement balance.

  • GET /v1/webhooks

    List webhook deliveries.

  • POST /v1/webhooks/test

    Send a ping event to WEBHOOK_URL.

The README lists every endpoint.