Slay Money API

Slay Money API

Programmatic access to a Slay wallet on the Canton Network.

v1.0.0 OpenAPI 3.1.0 https://slay-api-wallet-providers.slay-money-api.workers.dev openapi.json ↗

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 APIhttps://slay-api-wallet-providers.slay-money-api.workers.dev
Everything elsehttps://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":

CapabilityGrants
balance:readGET /api/v1/balance
tx:readGET /api/v1/transactions, GET /api/v1/transfers/{id}
tx:writePOST /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. ApplyPOST /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

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

200object

OK

Fields
key object
id string
name string
prefix string

Safe to log and to quote in a ticket. Not the secret.

Example: "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
Example: "25"
perDayCc string | null
Example: "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
Example: "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

GET /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
Default: 50
cursor string

The nextCursor from the previous page. Omit for the first.

Responses

200object

OK

Fields
wallets PartnerWallet[] required
nextCursor string | null required

Pass back as cursor. Null means no more pages.

{
  "wallets": []
}

Missing or unrecognised key.

The key is valid but lacks the capability, or a restriction blocked it.

Create a wallet for one of your users

POST /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.

Example: "cust-42"
label string

Free text for your own dashboards. Never interpreted here.

Example: "Maya"

Responses

This reference already had a wallet. Nothing was created.

Created.

Missing or unrecognised key.

The key is valid but lacks the capability, or a restriction blocked it.

externalRef missing or too long. The message says why it is required rather than only that it is.

One wallet, and whether it is ready

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

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

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

200object

OK

Fields
ref string
status "provisioning" | "ready" | "frozen"
balanceCc string
Example: "87.105300"
lockedCc string
Example: "0.000000"
availableCc string
Example: "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

GET /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
Default: 50
cursor string

Responses

200object

OK

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

POST /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.

Example: "b3f1c8e2-4d9a-4c77-8f0e-2a51d9c7e401"
to string required

A Slay handle, or a Canton party id (it contains ::).

Example: "karan"
amountCc string required

Positive decimal STRING. Not a number.

Example: "3.5"
memo string

Responses

201object

Settled.

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

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

200object

OK

Fields
balanceCc string required

Total held, decimal string.

Example: "87.105300"
lockedCc string required

Reserved and unspendable.

Example: "0.000000"
availableCc string required

balanceCc − lockedCc. Spend against this.

Example: "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.

Example: "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

GET /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.

Default: 50

Responses

200object

OK

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

POST /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.

Example: "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.

Example: "karan"
amountCc string required

Positive decimal STRING. Not a number.

Example: "3.0"
memo string

Free text shown to both parties.

Example: "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_approvedthe 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_allowedto 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

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

Found

Missing or unrecognised key.

The key is valid but lacks the capability, or a restriction blocked it.

No transfer with this id. Nothing was sent.

Provider

What this account is configured for. Read-only.

What this account is configured for

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

200object

OK

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.

Example: ["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.

Example: "0.25"
bps integer | null

Basis points (1 bp = 0.01%). Used when mode is bps.

Example: 250
maxCc 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%".

Example: "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

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

200object

Ready.

Fields
ok boolean
Example: true
db string
Example: "up"
ms integer

Database round-trip

{
  "ok": true,
  "db": "up",
  "ms": 0
}
503object

Running, but cannot reach the database.

Fields
ok boolean
Example: false
db string
Example: "down"
ms integer
{
  "ok": false,
  "db": "down",
  "ms": 0
}

Liveness

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

200object

OK

Fields
name string
Example: "slay-api-wallet-providers"
status string
Example: "ok"
surface string
Example: "wallet"
docs string
Example: "/docs"
openapi string
Example: "/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.

Example: "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.

Example: "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.

Example: "send"
amountCc string

Signed decimal string. Negative leaves the wallet.

Example: "-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.

Example: "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.

Example: "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.

Example: true
id string
createdAt string <date-time>
{
  "status": "settled",
  "amountCc": "1.894700",
  "partnerFeeCc": "0.075000",
  "partnerFeeCollected": true
}
Generated from openapi.yaml. Every field was read off the handler — where the code and a tidier description disagreed, the code won. Slay Money