Slay Money API
Programmatic access to a Slay wallet on the Canton Network.
Two ways in, for two different jobs:
API keys (/api/v1) — server-to-server. A key belongs to one wallet and carries mandatory restrictions: what it may do, how much it may move per transaction, which recipients it may pay, which IPs may use it. There is no unrestricted key.
CIP-0103 — browser dApps. Not described here because it is not an HTTP API: a dApp talks to the Slay browser extension, which implements CIP-0103 and is discovered at runtime via canton:announceProvider. Use @canton-network/dapp-sdk; nothing in this document is needed for that.
Which surface you want
Two, and picking wrong costs you a rewrite rather than a config change.
/api/partner/v1 — you operate wallets for your own users. You create them, one per customer, and move money out of each. This is the surface to build on: it pages with cursors, it tells you what your key can do, and creating a wallet is idempotent on your own customer id.
/api/v1 — one key, one wallet. The wallet the key belongs to, and no way to create another. Right for a single treasury or a payout account. Correct and supported, but not being extended: new capability goes to the partner surface.
If you are unsure, you want the partner surface. It does everything the single-wallet one does.
Start here
``bash curl https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/me \ -H "Authorization: Bearer sk_live_…" ``
That answers what your key can do, and whether the account is cleared to move money — which is worth knowing before you write the transfer that would have been refused. Issue a key from Dashboard → Build → API keys; the secret is shown once and is never retrievable.
Base URLs — there are two, and they are different Workers
| This API | https://slay-api-wallet-providers.slay-money-api.workers.dev |
| Everything else | https://slay-money-api.slay-money-api.workers.dev |
Only /api/v1 lives here. Issuing a key (POST /api/keys), applying for approval, prices, and every other Slay route are on the main API and are not reachable on this host — they answer 404 with a message saying so. The split is deliberate: this Worker deploys from a reviewed tag on a slow cadence, so Slay shipping a feature cannot move your traffic.
Getting a key
Issue one from Dashboard → Build → API keys (POST /api/keys on the main API, authenticated with a signed-in session — not with a key, and not on this host). Only a signed-in human can: a key cannot create another key, so a leaked one cannot quietly issue itself successors.
The screen is also where you freeze a key, rotate it, or revoke it. Rotation creates the successor first and leaves the old key valid for an hour, so a deployment does not need downtime. Freezing is the reversible one — there is a Freeze all control for the moment you are not yet sure what leaked.
At creation you choose exactly what it may do. There is no unrestricted key and no "tighten it later":
| Capability | Grants |
|---|---|
balance:read | GET /api/v1/balance |
tx:read | GET /api/v1/transactions, GET /api/v1/transfers/{id} |
tx:write | POST /api/v1/transfers — moving money |
tx:write additionally requires both spend caps, perTransactionCc and perDayCc. A key requesting it without them is rejected at creation with 422, not accepted and warned about. You may also pin it to specific recipients and to specific source IPs.
The secret is shown once, at creation, and hashed on the way in. There is no endpoint that returns it later. Lost means rotate.
Two things a valid key still cannot do
A key that authenticates correctly can still be refused, and the two reasons look nothing alike:
403 trading_not_approved — the account is not cleared to move money programmatically. Reads keep working. This is checked per request rather than baked into the key, so suspending an account stops every key it owns at once, with no propagation delay and no key hunting.
Getting cleared is a two-step thing, and minting a key is only the first:
1. Apply — POST /api/trading/apply on the main API (session, not a key) with your use case and expected monthly volume. State becomes pending. 2. An operator decides, and an approval always carries two account ceilings: a maximum per transaction and a maximum per UTC day.
Those ceilings bound the account, not one key. A key's own perTransactionCc/perDayCc may be lower and never higher — asking for more at creation silently stores the ceiling instead, and the response tells you what was actually saved. If an operator later lowers a ceiling, keys already issued are clamped down to it immediately; a limit that only applied to future keys would not be a limit.
GET /api/trading/status (main API, session) returns the current state and the ceilings. A rejected application can be resubmitted. A suspended one cannot — re-applying is refused, because a form should not be able to erase an operator's decision.
429 limit_exceeded — a spend cap, not a request rate. Despite the status, backing off does not help: perTransactionCc will never accept that amount, and perDayCc clears at 00:00 UTC. The message names which cap was hit and what has already been spent.
Billing, because it surprises people
Sends made by every wallet you own share one free-tier allowance and one daily account ceiling. Creating more wallets does not create more allowance.
That is deliberate. An allowance per sub-account would mean a provider with ten thousand wallets gets ten thousand free tiers, and a 250 CC/day approval would mean 250 × however many wallets you chose to create.
GET /api/partner/v1/me reports account.billsTo, so you can always see which account a wallet's spending counts against.
Amounts are decimal strings
amountCc goes over the wire as a string — "3.5", not 3.5. CC has six decimal places and IEEE-754 does not represent them exactly. A client that parses to a float, does arithmetic and formats back will eventually send someone the wrong number. Read them, compare them and store them as strings.
clientTxId is what makes a retry safe
Every transfer requires one, and it is the ONLY thing separating "my request timed out, try again" from "pay them twice". Generate it once per intended payment and reuse the identical value on every retry of that payment. The server matches on it and returns the original transfer instead of making a second one.
A timeout is not a failure. It means the outcome is unknown. Never retry with a fresh id — re-send the same id, or read GET /api/v1/transfers/{clientTxId} to find out what happened.
Partner
Wallets you create and operate for your own users. Base URL is the main API — https://slay-money-api.slay-money-api.workers.dev — not the host serving the single-wallet routes below.
What this key can do
/api/partner/v1/me
API key
Call this first.
It exists because the alternative is inferring your capabilities from a 403 and your spend limits from a 429. account.trading in particular is worth reading before you write a transfer: a key with every capability still cannot move money until the account is approved, and that check runs on every request rather than being stamped onto the key.
account.billsTo is the account your wallets' spending counts against. See Billing below — it is the part that surprises people.
curl -X GET "https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/me" \
-H "Authorization: Bearer $SLAY_API_KEY"const res = await fetch("https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/me", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.SLAY_API_KEY}`,
},
});
const data = await res.json();import os, requests
res = requests.get(
"https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/me",
headers={"Authorization": f"Bearer {os.environ['SLAY_API_KEY']}"}
)
data = res.json()Responses
objectOK
Fields
key
object
id
string
name
string
prefix
string
Safe to log and to quote in a ticket. Not the secret.
"sk_live_a1b2"capabilities
"partner:wallets:provision" | "partner:wallets:read" | "partner:wallets:write"[]
expiresAt
string | null <date-time>
lastUsedAt
string | null <date-time>
limits
object
This KEY's own caps, as decimal strings. Null means no cap of its own.
perTransactionCc
string | null
"25"perDayCc
string | null
"250"perMonthCc
string | null
allowedRecipients
integer
How many recipients this key is pinned to. 0 means any.
allowedIps
integer
How many source addresses it is pinned to. 0 means any.
account
object
id
string
trading
"none" | "pending" | "approved" | "rejected" | "suspended"
Whether the ACCOUNT may move money programmatically. Anything but approved and every transfer returns 403, however good the key is.
ceilings
object
Set by an operator at approval, and applied to your wallets in aggregate. Distinct from the key caps above: a second key does not buy more of these.
perTransactionCc
string | null
perDayCc
string | null
billsTo
string
The account this key's wallets bill to.
surface
string
"partner/v1"{
"key": {
"prefix": "sk_live_a1b2",
"capabilities": [
"partner:wallets:provision"
]
},
"limits": {
"perTransactionCc": "25",
"perDayCc": "250",
"allowedRecipients": 0,
"allowedIps": 0
},
"account": {
"trading": "none"
},
"surface": "partner/v1"
}
Missing or unrecognised key.
The key is valid but lacks the capability, or a restriction blocked it.
frozen — this key has been frozen, by its owner or by an operator.
It affects every route, reads included, which is what separates it from trading_not_approved. Freezing is reversible; retrying is not the fix, unfreezing is.
List your wallets
/api/partner/v1/wallets
API key
Requires partner:wallets:read. Newest first, cursor paged.
Cursors rather than offsets because rows are being written while you page, and offset paging silently skips or repeats under insertion. nextCursor is null when you have reached the end — it is not present-but-stale, so a client cannot loop over nothing.
curl -X GET "https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/wallets?limit=50" \
-H "Authorization: Bearer $SLAY_API_KEY"const res = await fetch("https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/wallets?limit=50", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.SLAY_API_KEY}`,
},
});
const data = await res.json();import os, requests
res = requests.get(
"https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/wallets?limit=50",
headers={"Authorization": f"Bearer {os.environ['SLAY_API_KEY']}"}
)
data = res.json()Query parameters
limit
integer
50cursor
string
The nextCursor from the previous page. Omit for the first.
Responses
Create a wallet for one of your users
/api/partner/v1/wallets
API key
Requires partner:wallets:provision.
Idempotent on externalRef, which is your own id for that user. Retry after a timeout, a redeploy or a queue redelivery and you get the same wallet back — 200 rather than 201, so you can tell which happened without it changing what you receive. The alternative is two wallets holding two balances for one person, which no later reconciliation fixes.
The wallet comes back provisioning. Allocating a Canton party is a validator round-trip and does not belong inside your signup loop, so it finishes in the background, usually within a few minutes. Balances read immediately; transfers return 409 wallet_provisioning until it is ready.
curl -X POST "https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/wallets" \
-H "Authorization: Bearer $SLAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"externalRef":"cust-42","label":"Maya"}'const res = await fetch("https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/wallets", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SLAY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"externalRef":"cust-42","label":"Maya"}),
});
const data = await res.json();import os, requests
res = requests.post(
"https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/wallets",
headers={"Authorization": f"Bearer {os.environ['SLAY_API_KEY']}"},
json={"externalRef":"cust-42","label":"Maya"}
)
data = res.json()Body
externalRef
string
required
Your id for this user. The idempotency key.
"cust-42"label
string
Free text for your own dashboards. Never interpreted here.
"Maya"Responses
One wallet, and whether it is ready
/api/partner/v1/wallets/{ref}
API key
Requires partner:wallets:read. Poll this after creation until status is ready.
A reference you do not own is 404, not 403 — whether somebody else's reference exists is not answerable here, and a 403 would answer it.
curl -X GET "https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/wallets/<ref>" \
-H "Authorization: Bearer $SLAY_API_KEY"const res = await fetch("https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/wallets/<ref>", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.SLAY_API_KEY}`,
},
});
const data = await res.json();import os, requests
res = requests.get(
"https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/wallets/<ref>",
headers={"Authorization": f"Bearer {os.environ['SLAY_API_KEY']}"}
)
data = res.json()Path parameters
ref
string
required
Your own id for this user.
Responses
OK
Missing or unrecognised key.
The key is valid but lacks the capability, or a restriction blocked it.
wallet_not_found — no wallet with that reference on this account.
Also what you get for a reference belonging to somebody else: 404 and not 403, because a 403 would confirm it exists.
Balance of one of your wallets
/api/partner/v1/wallets/{ref}/balance
API key
Requires partner:wallets:read. Works while a wallet is still provisioning.
Spend against availableCc, never balanceCc: locked funds are real money the holder owns and cannot move, so treating the total as spendable produces transfers the server refuses.
curl -X GET "https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/wallets/<ref>/balance" \
-H "Authorization: Bearer $SLAY_API_KEY"const res = await fetch("https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/wallets/<ref>/balance", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.SLAY_API_KEY}`,
},
});
const data = await res.json();import os, requests
res = requests.get(
"https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/wallets/<ref>/balance",
headers={"Authorization": f"Bearer {os.environ['SLAY_API_KEY']}"}
)
data = res.json()Path parameters
ref
string
required
Responses
objectOK
Fields
ref
string
status
"provisioning" | "ready" | "frozen"
balanceCc
string
"87.105300"lockedCc
string
"0.000000"availableCc
string
"87.105300"cantonAddress
string | null
{
"status": "provisioning",
"balanceCc": "87.105300",
"lockedCc": "0.000000",
"availableCc": "87.105300"
}
Missing or unrecognised key.
The key is valid but lacks the capability, or a restriction blocked it.
wallet_not_found — no wallet with that reference on this account.
Also what you get for a reference belonging to somebody else: 404 and not 403, because a 403 would confirm it exists.
History for one wallet
/api/partner/v1/wallets/{ref}/transactions
API key
Requires partner:wallets:read. Newest first, cursor paged — page all the way back, which the single-wallet surface cannot do.
clientTxId is populated for transfers this API created, so you can reconcile your own sends without keeping a separate mapping.
curl -X GET "https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/wallets/<ref>/transactions?limit=50" \
-H "Authorization: Bearer $SLAY_API_KEY"const res = await fetch("https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/wallets/<ref>/transactions?limit=50", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.SLAY_API_KEY}`,
},
});
const data = await res.json();import os, requests
res = requests.get(
"https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/wallets/<ref>/transactions?limit=50",
headers={"Authorization": f"Bearer {os.environ['SLAY_API_KEY']}"}
)
data = res.json()Path parameters
ref
string
required
Query parameters
limit
integer
50cursor
string
Responses
objectOK
Fields
items
Transaction[]
required
nextCursor
string | null
required
{
"items": []
}
Missing or unrecognised key.
The key is valid but lacks the capability, or a restriction blocked it.
wallet_not_found — no wallet with that reference on this account.
Also what you get for a reference belonging to somebody else: 404 and not 403, because a 403 would confirm it exists.
Send CC from one of your wallets
/api/partner/v1/wallets/{ref}/transfers
API key
Requires partner:wallets:write and tx:write, both spend caps on the key, and an approved account.
This moves money. clientTxId is required and is the only thing separating "my request timed out, try again" from "pay them twice". Generate it once per intended payment and reuse the identical value on every retry of that payment. A timeout is not a failure — it means the outcome is unknown; re-send the same id rather than a fresh one.
Three limits apply and they fail differently, so the code tells you which bit: the key's own caps (limit_exceeded), the account's ceilings across all your wallets (account_limit_exceeded), and the account's approval state (trading_not_approved).
curl -X POST "https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/wallets/<ref>/transfers" \
-H "Authorization: Bearer $SLAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"clientTxId":"b3f1c8e2-4d9a-4c77-8f0e-2a51d9c7e401","to":"karan","amountCc":"3.5"}'const res = await fetch("https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/wallets/<ref>/transfers", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SLAY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"clientTxId":"b3f1c8e2-4d9a-4c77-8f0e-2a51d9c7e401","to":"karan","amountCc":"3.5"}),
});
const data = await res.json();import os, requests
res = requests.post(
"https://slay-money-api.slay-money-api.workers.dev/api/partner/v1/wallets/<ref>/transfers",
headers={"Authorization": f"Bearer {os.environ['SLAY_API_KEY']}"},
json={"clientTxId":"b3f1c8e2-4d9a-4c77-8f0e-2a51d9c7e401","to":"karan","amountCc":"3.5"}
)
data = res.json()Path parameters
ref
string
required
Body
clientTxId
string
required
Your idempotency key. Reuse it verbatim when retrying.
"b3f1c8e2-4d9a-4c77-8f0e-2a51d9c7e401"to
string
required
A Slay handle, or a Canton party id (it contains ::).
"karan"amountCc
string
required
Positive decimal STRING. Not a number.
"3.5"memo
string
Responses
objectSettled.
Fields
ref
string
clientTxId
string
status
"settled"
amountCc
string
id
string
createdAt
string <date-time>
{
"status": "settled"
}
Missing clientTxId or to, or an amountCc that is not a positive decimal string.
Missing or unrecognised key.
The key is valid but lacks the capability, or a restriction blocked it.
wallet_not_found — no wallet with that reference on this account.
Also what you get for a reference belonging to somebody else: 404 and not 403, because a 403 would confirm it exists.
wallet_provisioning — no Canton party yet; poll the wallet until it is ready. Or frozen — the wallet or the key is switched off.
limit_exceeded — this key's cap. account_limit_exceeded — the account's ceiling across every wallet you own, where a different key will not help. Both are spend caps, not request rates: per transaction never passes for that amount, per day clears at 00:00 UTC.
Wallet
Balances and history for the wallet a key belongs to.
Balance for this key's wallet
/api/v1/balance
API key
Requires the balance:read capability.
availableCc is balanceCc − lockedCc and is the figure to check before spending. lockedCc is real money the holder owns but cannot move — reserved against open positions or in-flight transfers — so treating balanceCc as spendable will produce transfers the server refuses.
curl -X GET "https://slay-api-wallet-providers.slay-money-api.workers.dev/api/v1/balance" \
-H "Authorization: Bearer $SLAY_API_KEY"const res = await fetch("https://slay-api-wallet-providers.slay-money-api.workers.dev/api/v1/balance", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.SLAY_API_KEY}`,
},
});
const data = await res.json();import os, requests
res = requests.get(
"https://slay-api-wallet-providers.slay-money-api.workers.dev/api/v1/balance",
headers={"Authorization": f"Bearer {os.environ['SLAY_API_KEY']}"}
)
data = res.json()Responses
objectOK
Fields
balanceCc
string
required
Total held, decimal string.
"87.105300"lockedCc
string
required
Reserved and unspendable.
"0.000000"availableCc
string
required
balanceCc − lockedCc. Spend against this.
"87.105300"cantonAddress
string | null
The wallet's Canton party id. Null when no party has been allocated yet — the account exists but cannot transact.
"slay-money::12206cab144ff69861e34be8671ece597d978fd70c2e1d6fb2a5da8f17336796ef32"{
"balanceCc": "87.105300",
"lockedCc": "0.000000",
"availableCc": "87.105300",
"cantonAddress": "slay-money::12206cab144ff69861e34be8671ece597d978fd70c2e1d6fb2a5da8f17336796ef32"
}
Missing or unrecognised key.
The key is valid but lacks the capability, or a restriction blocked it.
frozen — this key has been frozen, by its owner or by an operator.
It affects every route, reads included, which is what separates it from trading_not_approved. Freezing is reversible; retrying is not the fix, unfreezing is.
limit_exceeded — a spend cap on the key, not a request rate.
Two different caps produce it, and they need opposite handling: perTransactionCc will never pass for this amount no matter how long you wait, and perDayCc resets at 00:00 UTC. Backing off and retrying is wrong for the first and slow for the second — read the message, which names which cap was hit and what was already spent.
Transaction history
/api/v1/transactions
API key
Requires tx:read. Newest first.
clientTxId is populated for transfers this API created, so a caller can reconcile its own sends against the ledger without keeping a separate mapping.
curl -X GET "https://slay-api-wallet-providers.slay-money-api.workers.dev/api/v1/transactions?limit=50" \
-H "Authorization: Bearer $SLAY_API_KEY"const res = await fetch("https://slay-api-wallet-providers.slay-money-api.workers.dev/api/v1/transactions?limit=50", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.SLAY_API_KEY}`,
},
});
const data = await res.json();import os, requests
res = requests.get(
"https://slay-api-wallet-providers.slay-money-api.workers.dev/api/v1/transactions?limit=50",
headers={"Authorization": f"Bearer {os.environ['SLAY_API_KEY']}"}
)
data = res.json()Query parameters
limit
integer
Rows to return.
50Responses
objectOK
Fields
items
Transaction[]
required
{
"items": []
}
Missing or unrecognised key.
The key is valid but lacks the capability, or a restriction blocked it.
frozen — this key has been frozen, by its owner or by an operator.
It affects every route, reads included, which is what separates it from trading_not_approved. Freezing is reversible; retrying is not the fix, unfreezing is.
limit_exceeded — a spend cap on the key, not a request rate.
Two different caps produce it, and they need opposite handling: perTransactionCc will never pass for this amount no matter how long you wait, and perDayCc resets at 00:00 UTC. Backing off and retrying is wrong for the first and slow for the second — read the message, which names which cap was hit and what was already spent.
Transfers
Moving CC. Requires the tx:write capability.
Send CC
/api/v1/transfers
API key
Requires tx:write, and that capability cannot exist without both a perTransactionCc and a perDayCc limit — the server rejects a key configured otherwise.
It also requires the account to be approved for programmatic trading. That is separate from the key and re-checked on every request; without it you get 403 trading_not_approved even with a perfectly good key.
This moves money. Read the clientTxId note in the API description before writing a retry loop. On a timeout, do not re-send with a new id: re-send the same one, or read GET /api/v1/transfers/{clientTxId}.
The transfer is also checked against the key's allowedRecipients and its spend limits before anything moves.
curl -X POST "https://slay-api-wallet-providers.slay-money-api.workers.dev/api/v1/transfers" \
-H "Authorization: Bearer $SLAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"clientTxId":"b3f1c8e2-4d9a-4c77-8f0e-2a51d9c7e401","to":"karan","amountCc":"3.0","memo":"invoice 41"}'const res = await fetch("https://slay-api-wallet-providers.slay-money-api.workers.dev/api/v1/transfers", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SLAY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({"clientTxId":"b3f1c8e2-4d9a-4c77-8f0e-2a51d9c7e401","to":"karan","amountCc":"3.0","memo":"invoice 41"}),
});
const data = await res.json();import os, requests
res = requests.post(
"https://slay-api-wallet-providers.slay-money-api.workers.dev/api/v1/transfers",
headers={"Authorization": f"Bearer {os.environ['SLAY_API_KEY']}"},
json={"clientTxId":"b3f1c8e2-4d9a-4c77-8f0e-2a51d9c7e401","to":"karan","amountCc":"3.0","memo":"invoice 41"}
)
data = res.json()Body
clientTxId
string
required
Your idempotency key. Reuse the identical value when retrying the same payment — it is how a retry is told apart from a second payment.
"b3f1c8e2-4d9a-4c77-8f0e-2a51d9c7e401"to
string
required
A Slay handle (karan, slay@karan) or a Canton party id (contains ::). The server resolves which and routes internally or on-chain accordingly.
"karan"amountCc
string
required
Positive decimal STRING. Not a number.
"3.0"memo
string
Free text shown to both parties.
"invoice 41"Responses
Settled. Also returned for a repeat of a clientTxId that already succeeded — the original transfer, not a new one.
Note the code: this is 201, not 200. A client that tests status === 200 will read every successful transfer as a failure, and may then retry money that already moved.
Missing clientTxId, missing to, or an amountCc that is not a positive decimal string. Also returned when the amount is below the network's minimum, which is denominated in USD and therefore moves with the CC price.
Missing or unrecognised key.
Four different refusals share this status, and each has its own code. Branch on code, never on the status alone:
- trading_not_approved — the account is not cleared to move money programmatically. A valid key with tx:write still gets this. It is re-checked on every request rather than stamped onto the key, so suspending an account disables all of its keys at once. Reads keep working. Not retryable. - capability_missing — the key does not carry tx:write. Mint a new key; a key's capabilities cannot be widened after creation. - recipient_not_allowed — to is not in this key's allowedRecipients. - token_not_enabled — the asset is not enabled for this account. See GET /api/v1/config.
frozen — this key has been frozen, by its owner or by an operator.
It affects every route, reads included, which is what separates it from trading_not_approved. Freezing is reversible; retrying is not the fix, unfreezing is.
limit_exceeded — a spend cap on the key, not a request rate.
Two different caps produce it, and they need opposite handling: perTransactionCc will never pass for this amount no matter how long you wait, and perDayCc resets at 00:00 UTC. Backing off and retrying is wrong for the first and slow for the second — read the message, which names which cap was hit and what was already spent.
Look up a transfer by your own id
/api/v1/transfers/{clientTxId}
API key
Requires tx:read. This is the correct move after a timeout: it answers whether the transfer happened, without risking a second one.
A 404 means no transfer with that id exists — nothing was sent, and it is safe to submit it.
curl -X GET "https://slay-api-wallet-providers.slay-money-api.workers.dev/api/v1/transfers/<clientTxId>" \
-H "Authorization: Bearer $SLAY_API_KEY"const res = await fetch("https://slay-api-wallet-providers.slay-money-api.workers.dev/api/v1/transfers/<clientTxId>", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.SLAY_API_KEY}`,
},
});
const data = await res.json();import os, requests
res = requests.get(
"https://slay-api-wallet-providers.slay-money-api.workers.dev/api/v1/transfers/<clientTxId>",
headers={"Authorization": f"Bearer {os.environ['SLAY_API_KEY']}"}
)
data = res.json()Path parameters
clientTxId
string
required
Responses
Provider
What this account is configured for. Read-only.
What this account is configured for
/api/v1/config
API key
The assets this account may move and the fee it adds on top of Slay's, as an operator configured them. Any valid key can read it; no key can change it.
Read-only on purpose. A key that could widen the assets it may touch, or lower the fee it charges, would not be a capped credential — those are account-level settings a signed-in human makes in the dashboard.
Absence of configuration means defaults: every supported asset enabled, no partner fee. An account that has never opened the settings screen behaves exactly as it did before the screen existed.
curl -X GET "https://slay-api-wallet-providers.slay-money-api.workers.dev/api/v1/config" \
-H "Authorization: Bearer $SLAY_API_KEY"const res = await fetch("https://slay-api-wallet-providers.slay-money-api.workers.dev/api/v1/config", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.SLAY_API_KEY}`,
},
});
const data = await res.json();import os, requests
res = requests.get(
"https://slay-api-wallet-providers.slay-money-api.workers.dev/api/v1/config",
headers={"Authorization": f"Bearer {os.environ['SLAY_API_KEY']}"}
)
data = res.json()Responses
objectOK
Fields
tokens
"cc" | "cbtc" | "ceth" | "tusd" | "hecto"[]
required
Assets enabled for this account. POST /api/v1/transfers moves CC only today; anything else here is enabled and not yet reachable through this API.
["cc","cbtc"]fee
object
required
Your take, not Slay's. Slay's base fee is charged by the send path regardless and is not represented here — nothing on this object can reduce, waive or redirect it.
mode
"none" | "flat" | "bps"
required
flatCc
string | null
Used when mode is flat. Decimal string.
"0.25"bps
integer | null
Basis points (1 bp = 0.01%). Used when mode is bps.
250maxCc
string | null
Ceiling on your take per send. Only meaningful for bps — a percentage of a large transfer is a large number, and 250bp of a 1000 CC send is 25 CC, which is rarely what anyone means by "2.5%".
"5"active
boolean
required
Whether a take is actually payable. mode alone is not enough: a fee with no destination party is not charged at all, so this is false even when mode is flat or bps.
freeTxnsPerDay
integer | null
Override for the free daily send allowance. Null = the global default.
{
"tokens": [
"cc",
"cbtc"
],
"fee": {
"mode": "none",
"flatCc": "0.25",
"bps": 250,
"maxCc": "5",
"active": true
},
"freeTxnsPerDay": 0
}
Missing or unrecognised key.
frozen — this key has been frozen, by its owner or by an operator.
It affects every route, reads included, which is what separates it from trading_not_approved. Freezing is reversible; retrying is not the fix, unfreezing is.
Public
No authentication. Use these for liveness and readiness checks.
Readiness — can this API actually serve a request
/health
No auth
Checks the shared database, which is what a request needs. Alert on this, not on /. A partner watching only / would page nobody during a database outage, because the Worker itself is fine.
Returns 503 with db: "down" when Postgres is unreachable, so the answer to "is it us or you" does not require a support ticket.
curl -X GET "https://slay-api-wallet-providers.slay-money-api.workers.dev/health"const res = await fetch("https://slay-api-wallet-providers.slay-money-api.workers.dev/health", {
method: "GET",
});
const data = await res.json();import os, requests
res = requests.get(
"https://slay-api-wallet-providers.slay-money-api.workers.dev/health"
)
data = res.json()Responses
objectReady.
Fields
ok
boolean
truedb
string
"up"ms
integer
Database round-trip
{
"ok": true,
"db": "up",
"ms": 0
}
objectRunning, but cannot reach the database.
Fields
ok
boolean
falsedb
string
"down"ms
integer
{
"ok": false,
"db": "down",
"ms": 0
}
Liveness
/
No auth
Answers without touching the database, so it stays up during an outage — which is exactly when you need to tell "the Worker is down" apart from "the database is down". Pair it with /health, never replace it.
curl -X GET "https://slay-api-wallet-providers.slay-money-api.workers.dev/"const res = await fetch("https://slay-api-wallet-providers.slay-money-api.workers.dev/", {
method: "GET",
});
const data = await res.json();import os, requests
res = requests.get(
"https://slay-api-wallet-providers.slay-money-api.workers.dev/"
)
data = res.json()Responses
objectOK
Fields
name
string
"slay-api-wallet-providers"status
string
"ok"surface
string
"wallet"docs
string
"/docs"openapi
string
"/openapi.json"{
"name": "slay-api-wallet-providers",
"status": "ok",
"surface": "wallet",
"docs": "/docs",
"openapi": "/openapi.json"
}
Authentication
API key
Every /api/v1 request carries one, as a bearer token:
`` Authorization: Bearer sk_live_a1b2c3_9f4e7d2c8b1a6503e4f7a9d2 ``
A key belongs to exactly one wallet and can never reach another. It is hashed on arrival, so the value is shown once at creation and cannot be recovered — if it is lost, rotate the key rather than hunting for it.
The endpoint that mints one is POST /api/keys on the main API. Some internals — the older /api/agents path, which still works, and the table behind it — use the word "agent". Same credential.
Keys are not interchangeable with sign-in. A session cookie will not authenticate here, and a key will not authenticate the routes a signed-in person uses. That separation is deliberate: a leaked key must stay a capped, revocable, audited credential rather than becoming account takeover.
Send it over HTTPS from a server you control. A key in browser JavaScript, a mobile binary, or a public repository is a key someone else has — treat a leak as an incident and rotate immediately.
Slay sign-in
Your own signed-in session — not something an integrator uses.
It appears here for one reason: POST /api/agents mints keys, and a key must not be able to mint another key. Only a signed-in human can create one, so that a compromised key cannot quietly issue itself successors.
Schemas
Error
error
string
required
Human-readable, safe to log. Wording may change.
code
"bad_request" | "client_tx_id_required" | "unsupported_token" | "invalid_key" | "capability_missing" | "ip_not_allowed" | "recipient_not_allowed" | "token_not_enabled" | "trading_not_approved" | "not_found" | "wallet_not_found" | "wallet_provisioning" | "frozen" | "limit_exceeded" | "account_limit_exceeded" | "key_revoked" | "key_expired" | "key_rotated" | "partner_api_disabled" | "forbidden" | "conflict" | "gone" | "unprocessable" | "rate_limited" | "unavailable" | "internal"
required
Stable. Branch on this, never on error.
{
"code": "bad_request"
}
PartnerWallet
ref
string
required
Your own id for this user — what you passed as externalRef.
"cust-42"status
"provisioning" | "ready" | "frozen"
required
provisioning until a Canton party is allocated, which happens in the background. Balances read meanwhile; transfers do not.
label
string | null
cantonAddress
string | null
Null while provisioning. The wallet's party on the ledger.
"slay-money::12206cab144ff69861e34be8671ece597d978fd70c2e1d6fb2a5da8f17336796ef32"createdAt
string <date-time>
required
{
"ref": "cust-42",
"status": "provisioning",
"cantonAddress": "slay-money::12206cab144ff69861e34be8671ece597d978fd70c2e1d6fb2a5da8f17336796ef32"
}
Transaction
id
string
type
string
e.g. send, receive, topup, house_fee.
"send"amountCc
string
Signed decimal string. Negative leaves the wallet.
"-3.000000"status
"pending" | "confirmed" | "failed"
memo
string | null
The user's memo. Internal bookkeeping the server appends is stripped before it reaches you.
clientTxId
string | null
Set for transfers created through this API.
createdAt
string <date-time>
{
"type": "send",
"amountCc": "-3.000000",
"status": "pending"
}
Transfer
clientTxId
string
required
status
"settled" | "pending" | "failed"
required
amountCc
string
required
What actually moved — which is not always what was requested. A transfer fee, when one applies, is taken from the amount, so the recipient receives this figure rather than the number you sent. Reconcile against this, never against your request.
"1.894700"partnerFeeCc
string
What you were actually PAID on this transfer — straight to the Canton party you configured, in this same transaction.
Taken from the amount, exactly like Slay's own fee: the sender is debited what they asked to send, and the recipient receives what is left after both fees. A fee added on top would charge the sender more than the number they passed in, which no API should do quietly.
"0.000000" when you have no fee configured, when it has no destination party, or when the fee outputs were stripped.
"0.075000"partnerFeeCollected
boolean
Whether it actually moved. Normally true.
Your fee is an extra output on the same transfer — not accrued, not settled later, and never a transfer of its own. A transfer burns roughly 5.8 KB of synchronizer traffic (~3.5 CC) whatever it carries, so paying a 0.25 CC fee separately would cost fourteen times the fee. As an extra output it costs the marginal bytes.
false means the retry fallback stripped the fee outputs. Then the whole amount went to the recipient, neither Slay nor you were paid, and partnerFeeCc is "0.000000" to match. Bill against these two fields, never against your own calculation.
trueid
string
createdAt
string <date-time>
{
"status": "settled",
"amountCc": "1.894700",
"partnerFeeCc": "0.075000",
"partnerFeeCollected": true
}