Security model
Why Tillsafe cannot move your money, how the checkout proves on the payer's device that an address pays you, and what a stolen key can and cannot do.
Tillsafe is non-custodial: it never holds your funds, and it holds no key that can move them. The design assumes that any single part can be compromised, including Tillsafe's own servers, and limits what each part could do.
What each part can and cannot do
| If someone has… | They can | They cannot |
|---|---|---|
| Your secret API key | Create invoices, read your data, record refunds. | Change your payout addresses (403 destination_immutable). In live mode, change your webhook URL or secret (403 live_change_requires_dashboard). Move any funds. |
| Your publishable key | What a payer's checkout does: read the payer's view of an invoice, pick a payment option, open an invoice from a payment link. | Anything else: it only reaches the checkout endpoints. |
| A copy of a webhook request | Replay it within 5 minutes, which your deduplication on the event id absorbs. | Forge or alter one: every body is signed. |
| Tillsafe's servers | Stop crediting or notifying. Offer wrong deposit addresses for new invoices. | Move funds at your addresses. Make a checkout with your pins show an address that does not pay you: it refuses. |
The last row is why the checkout's own check, below, matters, and why you should always give it pins.
Deposit addresses that only pay you
Every way a payer can pay ends at an address whose funds only you can move.
Forwarder contracts
A forwarder is a tiny contract at a CREATE2 address, deployed from a factory:
- No owner, no admin, no upgrade. The code has no owner, no arbitrary call, no self-destruct and no storage. Nobody, Tillsafe included, can change what it does after the address exists.
- It can only pay your payout address. The destination is fixed in the contract. An optional platform fee goes to a fixed fee recipient; the factory refuses a fee above 5%, the fee rounds down in your favour, and if paying the fee fails it goes to you instead.
- Anyone may trigger the forward. The caller chooses when, never where.
- The terms are part of the address. The CREATE2 salt commits to your destination, the fee recipient, the fee, the dust left behind and the slot number. Change any of them and it is a different address.
The same contracts run on TRON, which uses its own CREATE2 address prefix.
Not yet deployed. The forwarder contracts are not deployed on any public network yet. Live mode uses your own addresses (below) until they are; forwarder addresses appear in test mode only.
Your own addresses
With an EOA pool, your deposit addresses are ordinary wallet addresses that you own. Payers pay them, and the money stays there, under your keys, until you sweep it with an offline tool. Tillsafe never sees those keys. See importing your addresses and sweeping.
Bitcoin
Every BTC invoice gets a fresh address derived from your wallet's account-level public key (xpub). Only the public key is ever shared. See Bitcoin with your xpub.
Your payout addresses are pinned
Where your money ends up cannot be changed with an API key. Sending registered_destinations to
POST /v1/merchant/settings is refused with 403 destination_immutable. A change is made on the
dashboard, and:
- only an owner or admin can request it, with a passkey confirmation from the last 5 minutes;
- every owner and admin is emailed, and any of them can cancel it;
- it takes effect only after a delay, 24 hours by default.
So a stolen key or a stolen session cannot quietly redirect your money. In live mode the same protected flow covers your webhook URL and webhook secret. (The dashboard is in early access, like the API.)
The checkout checks every address on the payer's device
The checkout never simply displays the address the server sent. Each payment option carries an
address_derivation, and the checkout recomputes the address from it on the payer's device before it
shows anything:
Address kind (scheme) | What the checkout verifies |
|---|---|
Forwarder (forwarder_v1) | The factory and contract code are a known deployment shipped inside the checkout, the fee is within bounds and goes to a known fee recipient, and the CREATE2 address recomputes from your destination. It then shows the address it computed, not the server's string. |
Your own address (registered_address) | It cannot be recomputed, so it must appear in your pins. Without pins it is refused. |
Bitcoin (bip32) | With your account key pinned, it re-derives the address. Without the pin, it shows the server's address and does not call it verified. |
If any option fails its check, the checkout shows no address at all, so a payer can never be sent somewhere you did not agree to. A connected wallet payment is checked the same way: the payout address it pays must be pinned, or be the verified forwarder's destination.
Pins
Pins are your own list of the addresses that may receive payments, as <network>@<address> entries with
a CAIP-2 network, for example
tron:mainnet@TXYZ… or eip155:56@0xAbC…. You give them to the checkout through the pins option of the
embed loader, which puts them in the URL fragment, so they never reach any server. A
malformed pin, or two sources of pins that disagree, makes the checkout refuse rather than guess.
Always pass pins. With them, even a compromised Tillsafe server cannot make your checkout show an address that does not pay you.
Keys
- Two kinds, two modes. Secret keys (
cp_live_sk_…,cp_test_sk_…) stay on your server. Publishable keys (cp_live_pk_…,cp_test_pk_…) are the only keys a browser may hold, and they only reach the checkout endpoints. Test keys only see test-mode objects on testnets; an object from the other mode is a404. - A secret key in a URL is refused (
secret_key_in_url), so it does not end up in logs and browser history. The embed loader refuses anything but a publishable key, and only loads overhttps://. - Keys are stored as a keyed hash, never in plain text, and a secret key is shown once, when it is created. Creating or rolling a live key needs a passkey confirmation.
Webhooks
- Every delivery is signed with HMAC-SHA256 over the timestamp and the raw body, and your receiver rejects a timestamp more than 5 minutes old. See webhooks and signatures.
- Webhook URLs must be
https://, without credentials, and resolve to a public address. The address is checked again at every delivery, so a DNS change cannot point deliveries at your internal network. Redirects are never followed.
Refund screening
A payer's refund address is screened against the OFAC SDN list of digital-currency addresses and, on
mainnets, the token issuer's blacklist. A match puts the refund in review instead of ready_to_send. The
payer is never told which list matched; you see every check (refunds). Incoming
payments are not screened today.
Reporting a vulnerability
See the security page on the Tillsafe website.