Designing Non-Custodial Trading: API Key Scopes and Never Touching Withdrawals
"Non-custodial" is usually a marketing word. Used precisely, it is an architectural constraint, and constraints of this kind are interesting because they delete work rather than add it.
A trading bot needs to place orders on your behalf. Exchanges make that possible with API keys, and API keys carry scopes: read, trade, and on most venues, withdraw. So the design question is not whether to ask for permissions. It is which ones, and what to do when you get more than you asked for.
Key Takeaways
- A promise not to use a permission is weaker than not having it. We refuse keys that carry withdrawal permission rather than accepting and ignoring them.
- Refusing is a stronger guarantee than trusting, because it does not depend on our code being correct or our team being honest.
- If a connection flow writes to the user's account, those writes must come after the decision to accept, not before. Otherwise you change someone's settings on the way to telling them no.
- Not holding funds deletes an entire class of hard problems: no custody ledger, no reconciliation, no withdrawal queue.
- What replaces it is narrower and sharper: your system must be correct in real time, because it can never undo anything.
1. Refusing a permission beats promising not to use it
The obvious implementation is to ask users for a key with read and trade scopes, and simply never call the withdrawal endpoints. Most of the industry does this, and it is not unreasonable.
We do something stricter. If a key arrives carrying withdrawal permission, it is refused. The user sees a message telling them exactly what to do:
This API key has withdrawal permission enabled. Freya never withdraws funds, so keys with that permission are not accepted. Please create a key with Read and Trade only.
The same rule applies on all three exchanges we support. There is no override and no admin exception.
At first this looks like friction for its own sake. The key would work. The user went to some trouble creating it. Rejecting it means sending them back to the exchange to make another one.
The reason is that the two designs make different promises, and the difference only becomes visible on your worst day.
- "We never call withdrawal endpoints" is a claim about our behaviour. It holds as long as our code is correct, our dependencies are clean, and everyone with deploy access stays honest. Those are reasonable things to expect. They are not things a user can verify.
- "The key cannot withdraw" is a claim about the key. It holds regardless of what our code does, because the exchange enforces it, and the user can confirm it themselves in their own exchange settings.
The second is a weaker request and a stronger guarantee. Prefer designs whose safety property is enforced by someone other than you, especially when the user has no way to audit your side.
There is a smaller benefit that matters in practice: it makes the failure mode loud. A key with too much scope gets caught at connection time, in front of a user who is actively setting things up and can fix it in a minute. The alternative is that the over-scoped key sits there working fine, and nobody learns anything until it matters.
2. Say no before you touch anything
This is the detail I would most want another engineer to take away, because it is easy to get wrong and nearly invisible when you do.
Connecting an exchange is not purely a read operation. Depending on the venue, getting an account into a workable state can involve writing to it: adjusting an account mode, setting a position mode, and so on. These are legitimate steps that happen as part of setup.
Now consider the ordering. If the permission check happens after those writes, then a rejected key still leaves a trail. The user handed you a credential, you modified settings in their account, and then you told them no. From their side, nothing explains why their exchange configuration is different from how they left it.
So the rule we settled on: a refused credential must leave the user's account exactly as it was found. Any step that writes belongs after the decision to accept, never before it. Rejection has to be reachable without side effects.
Generalised, this is just transactional thinking applied to someone else's system. You cannot roll back a write to a third-party account, so the ordering is your only protection. When you cannot undo, sequence carefully.
One implementation note that saved us from a whole category of bugs: the refusal is expressed as an invalid result rather than a special flag, so every path that consumes a validation result handles it without having to know it exists. We have two connection flows, and both inherit the same refusal because neither of them had to remember to ask about it. Encoding a rule in a type that all consumers already read beats documenting it and hoping.
3. What the constraint deletes
Here is where the trade turns clearly favourable.
Funds never leave the user's exchange account. Bots trade with read-and-trade keys, and the balance stays where it started. What follows from that is a long list of things that simply do not exist in our system:
- No custody ledger, so no reconciliation between an internal balance and an external one
- No withdrawal queue, no approval workflow, no daily limits on trading capital
- No hot and cold wallet split for that capital, and no key management around it
- No question of what happens to user funds if the company disappears
Every one of those is an entire subsystem in a custodial product, and each is a place where money can go missing. They are absent here not because we solved them but because we never took on the thing that creates them.
I want to be precise rather than sweeping, though: this applies to trading capital. Platform balances used for subscriptions are a separate matter with their own handling. The non-custodial property is about the money that trades, and that is the money that matters most.
4. What it does not fix
A constraint that removes problems also removes the tools you would use to fix mistakes, and it is worth being honest about that.
Trades are final. With custody, an internal transfer can in principle be reversed. An order filled on someone else's exchange account cannot. All the engineering weight moves to being correct before the request leaves, rather than reconciling after it lands. That is a harder discipline than it sounds, and it is why so much of our work is about validation, precision, and idempotency.
Trade permission is still real permission. A leaked read-and-trade key cannot drain an account to an external address, which is the outcome people actually fear. But it can place orders. The blast radius is bounded, not zero, and it would be dishonest to describe it as zero.
The user still holds obligations. Key rotation, not reusing keys across services, and watching what else has access to the same account remain theirs. Our architecture bounds the damage; it does not make the key harmless.
What I would tell another team
- Ask for the narrowest scope, then verify you got it. Requesting less is not the same as receiving less.
- Prefer guarantees enforced outside your own code, particularly ones the user can check without trusting you.
- Make rejection side-effect free. If your flow writes to a third-party account, everything that writes goes after the decision to accept.
- Encode the rule where consumers already look. A rule expressed in a shared type does not need every caller to remember it.
The larger point is that "we won't do X" and "we can't do X" read similarly in a landing page and are completely different in an incident. Whenever you can move a promise from the first category to the second, the cost is usually small and the payoff shows up exactly when everything else is going wrong.
