YourCryptoPal Payouts API
Send SEPA payouts from your EUR balance, straight from your own systems.
Use the API from your server to pay any IBAN in the SEPA area, follow each payout until it is paid, read your balance and prices, and keep your address book in step. It works on the same account as your dashboard: payouts you send by API show up there, and the API lists payouts sent from the dashboard too.
https://payouts-api.yourcryptopal.com:5555/v1
The API answers on port 5555. If your firewall only lets ports 80 and 443 out, allow outgoing HTTPS to payouts-api.yourcryptopal.com on port 5555.
How a payout works
- Fund your balance. Top-ups come from your linked YourCryptoPal account, under Add funds in the dashboard, and are on your balance at once.
- Create a payout with
POST /payouts: the amount, who pays the fee (amountIs), the recipient and the reference on their statement. The whole debit, amount and fee, is taken from your balance at once. - Follow it. Its status moves from
queuedtoprocessingand thensucceeded(shown "Paid" in the dashboard). Our payout partner releases payouts to the banks twice a day, at varying times; until the next release the payout shows as processing. After the release it goes by SEPA Instant wherever the recipient's bank supports it. A webhook tells your server about each change, andGET /payouts/{id}returns the latest state. - Nothing is lost. A payout that fails, is returned within 14 days, is rejected or cancelled gives the whole debit back to your balance, fee included.
No codes in the API
Payouts sent by API never ask for a second-factor code. Your API key, its IP allowlist and your account's limits protect them, so keep the key on your server and give it an allowlist.
Quick start
- Create an API key. Sign in, open Developers › API keys and create a key with the
payouts.createandpayouts.readscopes. Creating a key asks for your code once. Copy the key straight away: it is shown only once. Keep it on your server, for example in an environment variable calledYCPP_API_KEY. - Send a payout with the request below. Use your own reference after
Payout-, and a newIdempotency-Keyfor each payout you mean to send. - Add a webhook endpoint under Developers › Webhooks so your server hears when the payout is paid, and check the signature of every request.
curl https://payouts-api.yourcryptopal.com:5555/v1/payouts \
-H "Authorization: Bearer $YCPP_API_KEY" \
-H "Idempotency-Key: inv-2026-0142" \
-H "Content-Type: application/json" \
-d '{
"amount": "1200.00",
"amountIs": "received",
"recipient": { "name": "Huber Elektrotechnik GmbH", "iban": "AT611904300234573201" },
"reference": "Payout-INV-2026-0142",
"externalId": "INV-2026-0142"
}'
// Node.js 18 or later (built-in fetch)
const res = await fetch("https://payouts-api.yourcryptopal.com:5555/v1/payouts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.YCPP_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "inv-2026-0142",
},
body: JSON.stringify({
amount: "1200.00",
amountIs: "received",
recipient: { name: "Huber Elektrotechnik GmbH", iban: "AT611904300234573201" },
reference: "Payout-INV-2026-0142",
externalId: "INV-2026-0142",
}),
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
console.log(body.id, body.status, body.debit.amount); // pyo_… queued 1207.75
# pip install requests
import os
import requests
res = requests.post(
"https://payouts-api.yourcryptopal.com:5555/v1/payouts",
headers={
"Authorization": f"Bearer {os.environ['YCPP_API_KEY']}",
"Idempotency-Key": "inv-2026-0142",
},
json={
"amount": "1200.00",
"amountIs": "received",
"recipient": {"name": "Huber Elektrotechnik GmbH", "iban": "AT611904300234573201"},
"reference": "Payout-INV-2026-0142",
"externalId": "INV-2026-0142",
},
timeout=30,
)
body = res.json()
if not res.ok:
raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
print(body["id"], body["status"], body["debit"]["amount"]) # pyo_… queued 1207.75
<?php
$ch = curl_init('https://payouts-api.yourcryptopal.com:5555/v1/payouts');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('YCPP_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: inv-2026-0142',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => '1200.00',
'amountIs' => 'received',
'recipient' => ['name' => 'Huber Elektrotechnik GmbH', 'iban' => 'AT611904300234573201'],
'reference' => 'Payout-INV-2026-0142',
'externalId' => 'INV-2026-0142',
]),
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 300) {
throw new RuntimeException($body['error']['code'] . ': ' . $body['error']['message']);
}
echo $body['id'], ' ', $body['status'], ' ', $body['debit']['amount']; // pyo_… queued 1207.75
// .NET 8 or later
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://payouts-api.yourcryptopal.com:5555/v1/") };
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("YCPP_API_KEY"));
using var request = new HttpRequestMessage(HttpMethod.Post, "payouts")
{
Content = JsonContent.Create(new
{
amount = "1200.00",
amountIs = "received",
recipient = new { name = "Huber Elektrotechnik GmbH", iban = "AT611904300234573201" },
reference = "Payout-INV-2026-0142",
externalId = "INV-2026-0142",
}),
};
request.Headers.Add("Idempotency-Key", "inv-2026-0142");
using var response = await http.SendAsync(request);
var payout = await response.Content.ReadFromJsonAsync<JsonElement>();
if (!response.IsSuccessStatusCode)
{
var error = payout.GetProperty("error");
throw new Exception($"{error.GetProperty("code")}: {error.GetProperty("message")}");
}
Console.WriteLine(payout.GetProperty("status").GetString()); // queued
The answer is 201 Created with the payout: at an API price of €0.50 + 0.60 %, Huber Elektrotechnik gets exactly €1,200.00, the fee is €7.75 and €1,207.75 is taken from your balance.
Authentication
Send your API key with every request, in the Authorization header:
Authorization: Bearer ycpp_live_7c2e9a41d0b3_…
The X-Api-Key: ycpp_live_… header works too. Always call the API over HTTPS.
- Keys start with
ycpp_live_, then a 12-character key id, an underscore and the secret. The dashboard shows the first part (ycpp_live_7c2e…) so you can tell your keys apart; the full key is shown once, when you create it. - A YourCryptoPal Merchants key (
ycp_live_…) doesn't work here, and a Payouts key doesn't work on the Merchants API. - Each key has scopes, an optional expiry and an optional IP allowlist (addresses or CIDR ranges, IPv4 or IPv6). With an allowlist, calls from any other address are refused. YourCryptoPal can require an allowlist for every key on your account.
- How many keys you can have is set for your account (3 unless agreed otherwise). Revoked and expired keys don't count. Revoking takes effect at once.
Keep keys secret
A key with payouts.create can send money without a code. Use keys only from your server, never in a web page, a mobile app or a code repository. If a key may have leaked, revoke it under Developers › API keys and create a new one.
When a key is refused
The checks run in this order; the first that fails answers.
| HTTP | Code | Meaning |
|---|---|---|
| 401 | unauthorized |
No key was sent, or the key is unknown, revoked or expired. |
| 403 | ip_not_allowed |
The key has an IP allowlist and the request came from another address, or your account requires an allowlist and the key has none. |
| 403 | account_frozen |
Your account is frozen; details.reason says why. Every call is refused. Contact support. |
| 403 | account_not_active |
Your account was closed. |
| 429 | rate_limited |
Too many requests with this key. See Rate limits. |
| 403 | forbidden |
The key doesn't have the scope this route needs; details.requiredScope names it. |
Scopes
Give each key only the scopes your integration needs. A key that only reads statuses for your bookkeeping needs payouts.read and nothing else.
| Scope | Lets the key |
|---|---|
payouts.create |
POST /payouts and POST /payouts/{id}/cancel: send payouts, and cancel them while they can still be cancelled |
payouts.read |
GET /payouts and GET /payouts/{id}: list payouts and read each one's status, fee and timeline |
balance.read |
GET /balance and GET /fees: your available balance, your prices and limits, and what you can send right now |
recipients.read |
GET /recipients and GET /recipients/{id}: read the address book |
recipients.manage |
POST /recipients and DELETE /recipients/{id}: add and delete address book entries, without a code |
GET /openapi.json needs no key.
Requests and responses
- JSON. Send bodies as JSON with
Content-Type: application/json. Responses are JSON in UTF-8. Property names are camelCase everywhere, also in webhooks:amountIs,recipientId,externalId. - Amounts you send are plain decimal strings in EUR:
"1200.00"or"1200", at most two decimals, no sign, no thousands separators. A JSON number is accepted too, but send strings. - Amounts you receive are Money objects with exactly two decimals:
{ "amount": "1210.69", "currency": "EUR" }. Parse them with a decimal type, never a float. Percentages are decimal strings meaning percent:"0.60"is 0.60 %. - Times are ISO 8601 in UTC with a
Z:2026-10-14T07:24:12Z. Thefromandtofilters take a date (2026-10-01, the whole UTC day) or a timestamp. - Ids are strings with a prefix:
pyo_…for payouts,rcp_…for address book entries,evt_…for webhook events. Store them as they are, don't parse them, and allow up to 64 characters. - Values such as statuses are lowercase with underscores:
awaiting_approval,sepa_instant. nullmeans "no value": every field is always present, for examplepaidAtisnulluntil the payout is paid.- New fields may be added to responses at any time. Ignore what you don't recognise. Unknown fields in your requests are ignored too.
Lists and pages
List routes take page (from 1) and pageSize (default 50, at most 200) and return the newest items first:
{ "items": [ … ], "page": 1, "pageSize": 50, "total": 23 }
total counts every item that matches your filters. A filter on a list of values takes several separated by commas: ?status=queued,processing.
Errors
Any status other than 2xx comes with an error body:
{
"error": {
"code": "limit_exceeded",
"message": "This payout takes €5,210.69 from your balance, more than your maximum of €5,000.00 per payout.",
"fields": { "amount": "The most one payout can take from your balance is €5,000.00." },
"details": {
"limit": "maximum",
"limitAmount": { "amount": "5000.00", "currency": "EUR" },
"limitCount": null,
"debit": { "amount": "5210.69", "currency": "EUR" }
},
"traceId": "00-6f1d0c2b9a7e4c55a1f0b2c3d4e5f607-1a2b3c4d5e6f7a8b-01"
}
}
- Branch on
code; it is stable. Themessageis written for people and may change. fieldsmaps each field of your request that has a problem (with dots for nested fields, such asrecipient.iban) to a sentence, or isnull.detailsisnullor an object whose shape is fixed for each code (see Error codes).- Quote the
traceIdwhen you contact support about a request.
The codes a payout integration should handle:
| Code | HTTP | When | What to do |
|---|---|---|---|
insufficient_balance |
422 | The debit is more than your available balance (details.available, details.debit) |
Add funds in the dashboard |
limit_exceeded |
422 | Below the minimum, above the maximum per payout, above what can be sent right now, or over the new-IBAN count. details.limit names which, and details.limitAmount or details.limitCount gives the figure |
Change the amount, split it, or try later |
invalid_iban |
422 | The IBAN failed the SEPA country, length or checksum check (details.problem) |
Fix the IBAN |
invalid_reference |
422 | The reference doesn't start with Payout-, is over 100 characters, has nothing after the prefix, or has characters that can't be sent (details.problem) |
Fix the reference |
validation_failed |
422 | Any other field problem; fields names each |
Fix the request |
idempotency_key_required |
400 | POST /payouts without an Idempotency-Key |
Send one |
conflict |
409 | The Idempotency-Key was used for a different request, or the first request with it is still running |
Use a new key, or wait and repeat |
invalid_state |
409 | Cancelling a payout that can no longer be cancelled (details.status) |
Nothing; it will finish |
already_exists |
409 | POST /recipients with an IBAN that is already saved (details.existingId) |
Use that entry |
limit_reached |
409 | The address book is full (500 entries) | Delete entries |
payouts_paused |
503 | Payouts are paused for everyone; no payout was created | Try later |
payouts_unavailable |
503 | What can be sent right now can't be worked out at the moment; no payout was created | Try later with the same Idempotency-Key |
account_frozen |
403 | Your account is frozen (details.reason); every call is refused |
Contact support |
account_not_active |
403 | Your account was closed | |
channel_disabled |
403 | API payouts are switched off for your account (details.channel is "api"); reading still works |
Contact support |
ip_not_allowed |
403 | The key has an IP allowlist and the caller isn't on it | Call from an allowed address |
forbidden |
403 | The key lacks the scope (details.requiredScope) |
Use a key with it |
unauthorized |
401 | Missing, unknown, revoked or expired key | |
rate_limited |
429 | Over the key's requests per minute; Retry-After says how many seconds to wait |
Slow down |
A 500 internal_error means something unexpected happened and nothing was changed. Repeat a payout request with the same Idempotency-Key: it never pays twice.
Rate limits
Each key may make a number of requests per minute, set for your account (600 unless agreed otherwise), over a sliding minute. Every answer says where you are:
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 598
Over the limit, the answer is 429 rate_limited with a Retry-After header in seconds. Wait that long and repeat; a payout request can be repeated with the same Idempotency-Key, because 429 answers are never stored. The API keys page in the dashboard shows your limit.
Idempotency
A network error can leave you not knowing whether a payout was created. The Idempotency-Key header makes it safe to try again: the same key always returns the payout already created, never a second one.
POST /payoutsrequires it (400 idempotency_key_requiredwithout it). The otherPOSTroutes accept it.- Use 1 to 100 printable ASCII characters that are unique per payout you mean to send, such as your invoice number or a UUID. Keys belong to the API key that sends them.
- The first answer is kept for 24 hours. A repeat with the same key and the same request gets the same status and body, with the header
Idempotent-Replayed: true. The replayed body is the payout as it was then; readGET /payouts/{id}for its current status. - The same key with a different method, path or body answers
409 conflict, and so does a repeat while the first request is still running. - Answers
401,403,429and5xxaren't kept, so you can repeat those with the same key.
The key is returned on the payout as idempotencyKey (it is null for payouts sent from the dashboard).
Payouts
A payout sends EUR from your balance to one IBAN by SEPA. Payouts sent from the dashboard and by API are the same object; channel tells them apart, and GET /payouts lists both.
| Field | Type | Description |
|---|---|---|
id |
string | Starts with pyo_ |
status |
string | See statuses |
processingStage |
string or null | While processing: awaiting_release until our payout partner releases it to the banks, then released |
rail |
string or null | Set when paid: sepa_instant or sepa_standard, the rail it actually took. null when that isn't known |
cancellable |
boolean | Whether cancel works now |
channel |
string | api or dashboard |
amountIs |
string | received or sent, see who pays the fee |
amount |
Money | The amount you sent in the request |
fee, debit, amountToRecipient |
Money | The fee, what was taken from your balance, and what the recipient gets. Worked out and stored when the payout is created; a later price change never changes them |
feeTerms |
object | The price used: { "fixed": Money, "percent" } |
feeBreakdown |
object | { "fixed", "percentPart", "percentOf", "rounding" }: the fixed part, the percentage part, the amount the percentage was taken of, and up or half_up |
reference |
string | The whole reference, starting with Payout- |
recipient |
object | { "name", "iban", "ibanMasked", "bic", "country", "recipientId", "inAddressBook", "savedWithPayout" }, a copy taken when the payout was created. iban is null in lists; ibanMasked looks like AT61 •••• 3201 |
externalId |
string or null | Your own id, as you sent it |
metadata |
object or null | Your own keys and values, as you sent them |
idempotencyKey |
string or null | The Idempotency-Key of the request that created it |
createdBy, cancelledBy |
Actor | { "type", "id", "name" }: your API key (api_key), you (user), or YourCryptoPal (admin, system) |
confirmation |
object or null | For dashboard payouts confirmed with a code: { "method", "at" }. Always null for API payouts |
refund |
object or null | { "amount": Money, "at" } once the whole debit is back on your balance |
rejectionReason |
string or null | Why YourCryptoPal rejected it |
requestedAt, createdAt, approvedAt, sentAt, releasedAt, paidAt, failedAt, returnedAt, rejectedAt, cancelledAt, updatedAt |
timestamps | When each step happened, or null |
timeline |
array | Only in GET /payouts/{id}: { "type", "at", "by", "data" } entries of type requested, code_confirmed, approval_required, approved, sent_to_partner, released, paid, failed, returned, rejected, cancelled and refunded |
Statuses
| Status | Dashboard | Meaning | Your balance |
|---|---|---|---|
awaiting_approval |
Waiting for approval | Its debit is above your approval limit, so YourCryptoPal checks it first. Can be cancelled | Taken |
queued |
Queued | Being handed to our payout partner. If the partner can't take it yet, it is tried again every 10 minutes and can be cancelled meanwhile (cancellable says when) |
Taken |
processing |
Processing | With our payout partner, waiting for the next release (processingStage) |
Taken |
succeeded |
Paid | Paid to the recipient's bank; rail says how |
Taken |
failed |
Failed | The payment couldn't be made | Given back, fee included |
returned |
Returned | Paid, then returned by the recipient's bank within 14 days | Given back, fee included |
rejected |
Rejected | YourCryptoPal didn't approve it (rejectionReason) |
Given back, fee included |
cancelled |
Cancelled | You cancelled it | Given back, fee included |
A paid payout can still become returned for 14 days. failed, returned, rejected and cancelled are final.
Fees: who pays
Your API price is a fixed fee plus a percentage, set for your account (GET /fees returns it). On every payout you say which amount you typed, with amountIs. There is no default, so no payout is priced in a way nobody chose.
amountIs |
The recipient gets | Taken from your balance | Rounding |
|---|---|---|---|
received (fee on top) |
Exactly amount |
(amount + fixed) × 100 ÷ (100 − percent); the fee is that minus amount |
Up to the cent, so what arrives is never short |
sent (fee included) |
amount − fee |
Exactly amount; the fee is fixed + percent of amount |
Half up to the cent |
At €0.50 + 0.60 %: "amount": "800.00", "amountIs": "received" takes (800.00 + 0.50) × 100 ÷ 99.40 = 805.332…, rounded up to €805.34; the fee is €5.34 and the recipient gets €800.00. With "amountIs": "sent", €800.00 is taken, the fee is 0.50 + 4.80 = €5.30 and the recipient gets €794.70.
In sent mode an amount whose fee would take all of it answers 422 validation_failed on amount. A payout that doesn't pay gives the whole debit back, fee included.
Create a payout
/v1/payoutspayouts.create
Sends a payout from your balance. The header Idempotency-Key is required.
| Field | Type | Description |
|---|---|---|
amount required |
decimal string | EUR, more than 0, at most two decimals |
amountIs required |
string | received: the recipient gets amount and the fee comes on top. sent: amount leaves your balance and the fee comes out of it. See who pays |
recipientId |
string | An entry of your address book. Send this or recipient, not both |
recipient |
object | { "name", "iban", "bic" }: the account holder's name, the IBAN and, if you have it, the BIC. See the rules |
reference required |
string | What the recipient sees on their statement. Starts with Payout-, at most 100 characters in all |
externalId |
string or null | Your own id, 1 to 100 printable ASCII characters. Not required to be unique; filter on it with ?externalId= |
metadata |
object or null | Up to 20 keys of 1 to 40 characters (A-Z a-z 0-9 _ - .), each with a string value of up to 500 characters. Returned as sent and never passed on |
The answer is 201 Created with the payout: awaiting_approval when its debit is above your approval limit, otherwise queued. Nothing is taken from your balance unless the answer is 201. Once created, a payout is never refused afterwards for a reason on our side: if our payout partner can't take it at once, it stays queued and is tried again every 10 minutes.
{
"id": "pyo_1Rz7Kd4Mw8",
"status": "queued",
"processingStage": null,
"rail": null,
"cancellable": false,
"channel": "api",
"amountIs": "received",
"amount": { "amount": "1200.00", "currency": "EUR" },
"fee": { "amount": "7.75", "currency": "EUR" },
"debit": { "amount": "1207.75", "currency": "EUR" },
"amountToRecipient": { "amount": "1200.00", "currency": "EUR" },
"feeTerms": { "fixed": { "amount": "0.50", "currency": "EUR" }, "percent": "0.60" },
"feeBreakdown": {
"fixed": { "amount": "0.50", "currency": "EUR" },
"percentPart": { "amount": "7.25", "currency": "EUR" },
"percentOf": { "amount": "1207.75", "currency": "EUR" },
"rounding": "up"
},
"reference": "Payout-INV-2026-0142",
"recipient": {
"name": "Huber Elektrotechnik GmbH",
"iban": "AT611904300234573201",
"ibanMasked": "AT61 •••• 3201",
"bic": null,
"country": "AT",
"recipientId": null,
"inAddressBook": false,
"savedWithPayout": false
},
"externalId": "INV-2026-0142",
"metadata": null,
"idempotencyKey": "inv-2026-0142",
"createdBy": { "type": "api_key", "id": "pkey_k7q2Mx8Rw1", "name": "Accounting sync" },
"confirmation": null,
"refund": null,
"rejectionReason": null,
"cancelledBy": null,
"requestedAt": "2026-10-14T12:21:30Z",
"createdAt": "2026-10-14T12:21:30Z",
"approvedAt": null,
"sentAt": null,
"releasedAt": null,
"paidAt": null,
"failedAt": null,
"returnedAt": null,
"rejectedAt": null,
"cancelledAt": null,
"updatedAt": "2026-10-14T12:21:30Z",
"timeline": [
{
"type": "requested",
"at": "2026-10-14T12:21:30Z",
"by": { "type": "api_key", "id": "pkey_k7q2Mx8Rw1", "name": "Accounting sync" },
"data": { "channel": "api", "debit": { "amount": "1207.75", "currency": "EUR" } }
}
]
}
To pay a saved recipient, send its id instead of the details:
{
"amount": "500.00",
"amountIs": "sent",
"recipientId": "rcp_6Wd2Np8Ks4",
"reference": "Payout-Commission Sep 2026",
"metadata": { "period": "2026-09" }
}
Recipient and reference
- IBAN. Spaces and hyphens are removed and letters upper-cased; that form is stored and returned. The country must be in the SEPA area (the 36 SEPA countries and Gibraltar) with exactly its length, and the checksum must be right. Otherwise
422 invalid_ibanwithdetails.problemformat,country,lengthorchecksum. - Name. The account holder's name, 2 to 70 characters of SEPA text: letters (also
À-ÿ), digits, space and/ - ? : ( ) . , ' + &. Otherwise422 validation_failedonrecipient.name. Banks don't check the name against the IBAN, so make sure both are right. - BIC is optional: 8 or 11 characters, such as
OPSKATWW. Otherwise422 validation_failedonrecipient.bic. - Reference. Starts with exactly
Payout-(with a capital P), then at least one character of your own, at most 100 characters in all, so up to 93 of your own. OnlyA-Z a-z 0-9, space and/ - ? : ( ) . , ' +(no&, no accented letters). Otherwise422 invalid_referencewithdetails.problemprefix,empty,lengthorcharacters.
Limits and checks
Limits apply to the debit, the amount taken from your balance with the fee. GET /fees returns yours.
| Limit | Over it |
|---|---|
| Minimum per payout (€25.00 unless agreed otherwise) | 422 limit_exceeded, details.limit minimum |
| Maximum per payout (€5,000.00 unless agreed otherwise) | 422 limit_exceeded, maximum |
What can be sent right now, which can be lower than your maximum for a short while: channels.api.canSendNow in GET /fees |
422 limit_exceeded, live_maximum, with the largest debit allowed now in details.limitAmount |
| IBANs your account never paid or saved before, per rolling 24 hours (10 unless agreed otherwise) | 422 limit_exceeded, new_recipients_24h, with the count in details.limitCount |
| Your available balance | 422 insufficient_balance |
| Approval limit, if your account has one | Not an error: the payout waits as awaiting_approval |
There is no daily or monthly limit. The checks run in a fixed order, and the first that fails answers: your key and its scope, the Idempotency-Key, your account, the API channel, the global pause, the body, the price, the minimum and maximum, the new-IBAN count, your balance, and what can be sent right now.
Retrieve a payout
/v1/payouts/{id}payouts.read
Returns the payout with its timeline. Works for payouts sent from the dashboard too. An id that isn't one of your payouts answers 404 not_found.
curl https://payouts-api.yourcryptopal.com:5555/v1/payouts/pyo_1Rz7Kd4Mw8 \
-H "Authorization: Bearer $YCPP_API_KEY"
"timeline": [
{ "type": "requested", "at": "2026-10-14T12:21:30Z", "by": { "type": "api_key", "id": "pkey_k7q2Mx8Rw1", "name": "Accounting sync" }, "data": { "channel": "api", "debit": { "amount": "1207.75", "currency": "EUR" } } },
{ "type": "sent_to_partner", "at": "2026-10-14T12:21:32Z", "by": null, "data": null },
{ "type": "released", "at": "2026-10-14T15:02:10Z", "by": null, "data": null },
{ "type": "paid", "at": "2026-10-14T15:02:14Z", "by": null, "data": { "rail": "sepa_instant" } }
]
List payouts
/v1/payoutspayouts.read
Payouts from the API and the dashboard, newest first, without timeline and with recipient.iban set to null (the masked IBAN is there).
| Query | Type | Description |
|---|---|---|
status |
string | One status or several, comma-separated: ?status=failed,returned |
from, to |
date or timestamp | Created in this period. A date means the whole UTC day |
externalId |
string | Only payouts with this externalId |
recipientId |
string | Only payouts to this address book entry |
page, pageSize |
number | See lists and pages |
curl "https://payouts-api.yourcryptopal.com:5555/v1/payouts?externalId=INV-2026-0142" \
-H "Authorization: Bearer $YCPP_API_KEY"
Cancel a payout
/v1/payouts/{id}/cancelpayouts.create
Cancels a payout that hasn't reached our payout partner: one that is awaiting_approval, or queued while it waits to be tried again. cancellable on the payout says whether it can be cancelled now. The answer is 200 with the payout, status cancelled, and the whole debit is back on your balance. Otherwise 409 invalid_state with details.status: a payout that is being sent may already be with the partner. It works for dashboard payouts too. No body.
curl -X POST https://payouts-api.yourcryptopal.com:5555/v1/payouts/pyo_1Rz7Kd4Mw8/cancel \
-H "Authorization: Bearer $YCPP_API_KEY"
Balance
/v1/balancebalance.read
available is what you can send now. inProgress is what payouts on their way have already taken from it, and how many there are. There are no holds: every payout takes its debit when it is created.
{
"available": { "amount": "18420.55", "currency": "EUR" },
"inProgress": { "amount": { "amount": "2416.80", "currency": "EUR" }, "count": 3 }
}
The balance statement, with every credit and debit and a CSV export, is in the dashboard.
Prices and limits
/v1/feesbalance.read
Your prices per channel, your limits, and the most one payout can take from your balance right now. Your API price is channels.api; channels.api.canSendNow.amount is the largest debit one API payout can have at this moment, and limitedBy says why (maximum, balance, live_maximum, paused, unavailable, channel_disabled, account_frozen or account_not_active).
{
"channels": {
"dashboard": { "enabled": true, "fixed": { "amount": "1.00", "currency": "EUR" }, "percent": "0.80", "canSendNow": { … } },
"api": {
"enabled": true,
"fixed": { "amount": "0.50", "currency": "EUR" },
"percent": "0.60",
"canSendNow": {
"amount": { "amount": "5000.00", "currency": "EUR" },
"minimum": { "amount": "25.00", "currency": "EUR" },
"maximum": { "amount": "5000.00", "currency": "EUR" },
"limitedBy": "maximum",
"available": true
}
}
},
"topupPercent": "0.00",
"limits": {
"minimum": { "amount": "25.00", "currency": "EUR" },
"maximum": { "amount": "5000.00", "currency": "EUR" },
"approvalAbove": null,
"addressBookCap": null,
"newRecipientsPer24h": 10
},
"payoutsPaused": false,
"examples": [
{ "channel": "api", "amountIs": "received", "amount": { "amount": "800.00", "currency": "EUR" }, "fee": { "amount": "5.34", "currency": "EUR" }, "debit": { "amount": "805.34", "currency": "EUR" }, "amountToRecipient": { "amount": "800.00", "currency": "EUR" } }
]
}
approvalAbove is the debit above which a payout waits for approval (null: never). addressBookCap only matters in the dashboard. available is false when what you can send is below the minimum.
Address book
The same address book as in the dashboard: one entry per IBAN, at most 500. Changes by API need no code, and the owner of the account is told about each one by email.
/v1/recipientsrecipients.read
/v1/recipients/{id}recipients.read
/v1/recipientsrecipients.manage
/v1/recipients/{id}recipients.manage
- The list takes
search(label, holder name or IBAN),sort(last_paid, the default,nameorcreated),pageandpageSize. POSTtakes{ "label", "name", "iban", "bic" };label(up to 70 characters, default the holder name) andbicare optional. The name, IBAN and BIC follow the payout rules. The answer is201with the entry. An IBAN already saved answers409 already_existswithdetails.existingId; a full address book409 limit_reached. A new IBAN counts towards the new-IBAN limit.DELETEanswers204. Payouts made to the entry keep their own copy of the name and IBAN, withrecipient.inAddressBooknowfalse.
{
"id": "rcp_6Wd2Np8Ks4",
"label": "Jonas Weber",
"name": "Jonas Weber",
"iban": "DE89370400440532013000",
"ibanMasked": "DE89 •••• 3000",
"bic": null,
"country": "DE",
"createdAt": "2026-10-14T07:25:03Z",
"createdBy": { "type": "user", "id": "pus_7Hq2Lm9Xc4", "name": "Lena Fischer" },
"confirmedWith": "telegram",
"updatedAt": "2026-10-14T07:25:03Z",
"lastPaidAt": "2026-10-14T09:38:00Z",
"lastPaidAmount": { "amount": "1200.00", "currency": "EUR" },
"payoutCount": 1
}
confirmedWith is the code that saved the entry in the dashboard, or null for one added by API. lastPaidAt, lastPaidAmount and payoutCount count paid payouts.
Webhooks
Instead of asking for each payout's status, let us tell your server. Add up to 5 endpoints under Developers › Webhooks, each with the events it should get. Every event is a signed POST to your https:// URL. When you add an endpoint you see its signing secret (whsec_…) once.
The request
POST /webhooks/ycp HTTP/1.1
Content-Type: application/json
User-Agent: YourCryptoPal-Webhooks/1.0
X-YCP-Event: payout.processing
X-YCP-Delivery: whd_0d4e6f8a2b1c4e3f9a7b5c3d1e2f4a6b
X-YCP-Signature: t=1792066082,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "evt_0b6e2f41c9a84d37b5e1a2c3d4e5f607",
"type": "payout.processing",
"createdAt": "2026-10-14T12:08:02Z",
"accountId": "pac_4Rk9TzQm2Wx7Lb",
"data": {
"payout": {
"id": "pyo_5TQW8L2HXA",
"status": "processing",
"processingStage": "awaiting_release",
"channel": "api",
"amountIs": "received",
"amount": { "amount": "800.00", "currency": "EUR" },
"fee": { "amount": "5.34", "currency": "EUR" },
"debit": { "amount": "805.34", "currency": "EUR" },
"amountToRecipient": { "amount": "800.00", "currency": "EUR" },
"reference": "Payout-PO-55870",
"externalId": "PO-55870",
"sentAt": "2026-10-14T12:08:02Z",
"updatedAt": "2026-10-14T12:08:02Z",
…
}
}
}
data.payout is shortened here: the event carries every field of the payout except timeline.
Events
| Event | Sent when | data |
|---|---|---|
payout.created |
Any new payout, from the API or the dashboard, right after it is created (status awaiting_approval or queued) |
{ "payout" } |
payout.awaiting_approval |
A new payout waits for YourCryptoPal's approval (sent right after payout.created) |
{ "payout" } |
payout.processing |
Our payout partner has accepted the payout | { "payout" } |
payout.succeeded |
The payout is paid; rail is set |
{ "payout" } |
payout.failed |
The payment couldn't be made; the whole debit is back (refund) |
{ "payout" } |
payout.returned |
A paid payout was returned within 14 days; the whole debit is back | { "payout" } |
payout.cancelled |
You cancelled it, by API or in the dashboard; the whole debit is back | { "payout" } |
payout.rejected |
YourCryptoPal rejected it (rejectionReason); the whole debit is back |
{ "payout" } |
balance.credited |
Money was added: a top-up was credited, or YourCryptoPal added an adjustment | { "entry", "balance", "topup" }: the statement entry, your new balance, and the top-up or null |
No event is sent when an approved payout moves on to queued, while a payout waits to be tried again, or when processingStage changes. The test button in the dashboard sends webhook.test with data: { "message": "This is a test event from YourCryptoPal Payouts." }; it can't be chosen as an event.
Verify signatures
Check every request before you trust it:
- Read the raw body, exactly as received, before any JSON parsing.
- Take
tand eachv1value from theX-YCP-Signatureheader. While a secret is being rotated there can be more than onev1. - Compute HMAC-SHA256 of
{t}.{raw body}with your signing secret (the wholewhsec_…string, as UTF-8) as the key, as lowercase hex. - Compare it with each
v1value in constant time. At least one must match. - Refuse the request if
tis more than 5 minutes from your clock, so an old request can't be replayed.
The signature is the same as for YourCryptoPal Merchants webhooks, so one verification works for both.
// Node.js 18 or later with Express
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.YCPP_WEBHOOK_SECRET; // whsec_...
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = header.split(",").map((p) => p.trim().split("="));
const t = Number(parts.find(([k]) => k === "t")?.[1]);
const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`, "utf8").digest("hex");
return sigs.some((s) => s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}
// Use the raw body: the signature covers the exact text that was sent.
app.post("/webhooks/ycp", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verify(rawBody, req.get("X-YCP-Signature") || "", SECRET)) {
return res.status(400).send("Bad signature");
}
const event = JSON.parse(rawBody);
res.sendStatus(200); // answer first, then do the work
handleEvent(event); // your code: process each event.id once
});
<?php
$secret = getenv('YCPP_WEBHOOK_SECRET'); // whsec_...
$body = file_get_contents('php://input'); // the raw body, before json_decode
$header = $_SERVER['HTTP_X_YCP_SIGNATURE'] ?? '';
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') $t = $value;
elseif ($key === 'v1') $signatures[] = $value;
}
$valid = false;
if ($t !== null && ctype_digit($t) && abs(time() - (int) $t) <= 300) {
$expected = hash_hmac('sha256', $t . '.' . $body, $secret);
foreach ($signatures as $signature) {
$valid = $valid || hash_equals($expected, $signature);
}
}
if (!$valid) {
http_response_code(400);
exit('Bad signature');
}
$event = json_decode($body, true);
http_response_code(200);
// Handle $event['type'] once per $event['id']
# Flask
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["YCPP_WEBHOOK_SECRET"].encode() # whsec_...
def verify(header: str, raw_body: bytes) -> bool:
t, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
t = value
elif key == "v1":
signatures.append(value)
if not t or not t.isdigit() or not signatures or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(SECRET, t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)
@app.post("/webhooks/ycp")
def ycp_webhook():
if not verify(request.headers.get("X-YCP-Signature", ""), request.get_data()):
abort(400)
event = request.get_json()
# Handle event["type"] once per event["id"]
return "", 200
// ASP.NET Core minimal API
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
var secret = Environment.GetEnvironmentVariable("YCPP_WEBHOOK_SECRET")!; // whsec_...
app.MapPost("/webhooks/ycp", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body, Encoding.UTF8);
var body = await reader.ReadToEndAsync(); // the raw body
if (!Verify(request.Headers["X-YCP-Signature"].ToString(), body, secret))
return Results.BadRequest();
using var ev = JsonDocument.Parse(body);
// Handle ev.RootElement.GetProperty("type") once per ev.RootElement.GetProperty("id")
return Results.Ok();
});
static bool Verify(string header, string body, string secret)
{
string? t = null;
var signatures = new List<string>();
foreach (var part in header.Split(',', StringSplitOptions.TrimEntries))
{
var i = part.IndexOf('=');
if (i <= 0) continue;
if (part[..i] == "t") t = part[(i + 1)..];
else if (part[..i] == "v1") signatures.Add(part[(i + 1)..]);
}
if (t is null || !long.TryParse(t, out var unix)
|| Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - unix) > 300)
return false;
var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{t}.{body}"));
return signatures.Any(s =>
{
try { return CryptographicOperations.FixedTimeEquals(Convert.FromHexString(s), expected); }
catch (FormatException) { return false; }
});
}
Answering, retries and order
- Answer with any 2xx status within 10 seconds. Do slow work after answering. Anything else (another status, a timeout, a connection error) counts as a failure.
- Failed deliveries are tried again, 8 attempts in all: about 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours after the first. The dashboard shows every delivery for 30 days and can send one again.
- An endpoint that keeps failing for more than 3 days is switched off, and we email you about it. Switch it back on in the dashboard once it's fixed.
- Events can arrive late, twice or out of order. Process each event
idonce, and readGET /payouts/{id}when you need the current state.
Error codes
Every code the API can answer, with the shape of its details.
| Code | HTTP | details |
|---|---|---|
invalid_json |
400 | null. The body isn't JSON, or a value has the wrong type |
invalid_request |
400 | null. A header or query parameter is malformed, such as an Idempotency-Key over 100 characters |
idempotency_key_required |
400 | null |
unauthorized |
401 | null |
forbidden |
403 | { "requiredScope": "payouts.create" } |
ip_not_allowed |
403 | null |
account_frozen |
403 | { "reason": "…" } |
account_not_active |
403 | null |
channel_disabled |
403 | { "channel": "api" } |
not_found |
404 | null. No such route, or the object isn't yours |
conflict |
409 | null |
already_exists |
409 | { "existingId": "rcp_…" } |
limit_reached |
409 | { "limit": "recipients", "max": 500 } |
invalid_state |
409 | { "status": "processing" } |
validation_failed |
422 | null; fields names each problem |
invalid_iban |
422 | { "problem": "checksum" }: format, country, length or checksum |
invalid_reference |
422 | { "problem": "characters" }: prefix, empty, length or characters |
limit_exceeded |
422 | { "limit", "limitAmount", "limitCount", "debit" }; limit is minimum, maximum, live_maximum or new_recipients_24h |
insufficient_balance |
422 | { "available": Money, "debit": Money } |
rate_limited |
429 | null; the Retry-After header says how many seconds to wait |
internal_error |
500 | null. Nothing was changed |
payouts_paused |
503 | null |
payouts_unavailable |
503 | null |
OpenAPI
A machine-readable description of every route is at GET /v1/openapi.json on the API (open it), an OpenAPI 3.1 document for code generators and API tools. It needs no key. This page is the complete guide: where the two differ, go by this page.