YourCryptoPal Payouts API docs
API keys Sign in

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.

Base URL 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

  1. Fund your balance. Top-ups come from your linked YourCryptoPal account, under Add funds in the dashboard, and are on your balance at once.
  2. 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.
  3. Follow it. Its status moves from queued to processing and then succeeded (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, and GET /payouts/{id} returns the latest state.
  4. 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

  1. Create an API key. Sign in, open Developers › API keys and create a key with the payouts.create and payouts.read scopes. 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 called YCPP_API_KEY.
  2. Send a payout with the request below. Use your own reference after Payout-, and a new Idempotency-Key for each payout you mean to send.
  3. Add a webhook endpoint under Developers › Webhooks so your server hears when the payout is paid, and check the signature of every request.
curl
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
// 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
Python
# 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
<?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
C#
// .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:

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. The from and to filters 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.
  • null means "no value": every field is always present, for example paidAt is null until 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:

Response
{ "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:

Response · 422
{
  "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. The message is written for people and may change.
  • fields maps each field of your request that has a problem (with dots for nested fields, such as recipient.iban) to a sentence, or is null.
  • details is null or an object whose shape is fixed for each code (see Error codes).
  • Quote the traceId when 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:

Response headers
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 /payouts requires it (400 idempotency_key_required without it). The other POST routes 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; read GET /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, 429 and 5xx aren'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

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

Response · 201
{
  "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:

Request body
{
  "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_iban with details.problem format, country, length or checksum.
  • Name. The account holder's name, 2 to 70 characters of SEPA text: letters (also À-ÿ), digits, space and / - ? : ( ) . , ' + &. Otherwise 422 validation_failed on recipient.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. Otherwise 422 validation_failed on recipient.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. Only A-Z a-z 0-9, space and / - ? : ( ) . , ' + (no &, no accented letters). Otherwise 422 invalid_reference with details.problem prefix, empty, length or characters.

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

GET/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
curl https://payouts-api.yourcryptopal.com:5555/v1/payouts/pyo_1Rz7Kd4Mw8 \
  -H "Authorization: Bearer $YCPP_API_KEY"
Response · 200 (the timeline of a paid payout)
"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

GET/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
curl "https://payouts-api.yourcryptopal.com:5555/v1/payouts?externalId=INV-2026-0142" \
  -H "Authorization: Bearer $YCPP_API_KEY"

Cancel a payout

POST/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
curl -X POST https://payouts-api.yourcryptopal.com:5555/v1/payouts/pyo_1Rz7Kd4Mw8/cancel \
  -H "Authorization: Bearer $YCPP_API_KEY"

Balance

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

Response · 200
{
  "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

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

Response · 200 (shortened)
{
  "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.

GET/v1/recipientsrecipients.read
GET/v1/recipients/{id}recipients.read
POST/v1/recipientsrecipients.manage
DELETE/v1/recipients/{id}recipients.manage
  • The list takes search (label, holder name or IBAN), sort (last_paid, the default, name or created), page and pageSize.
  • POST takes { "label", "name", "iban", "bic" }; label (up to 70 characters, default the holder name) and bic are optional. The name, IBAN and BIC follow the payout rules. The answer is 201 with the entry. An IBAN already saved answers 409 already_exists with details.existingId; a full address book 409 limit_reached. A new IBAN counts towards the new-IBAN limit.
  • DELETE answers 204. Payouts made to the entry keep their own copy of the name and IBAN, with recipient.inAddressBook now false.
Recipient
{
  "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

Headers
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
Body
{
  "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:

  1. Read the raw body, exactly as received, before any JSON parsing.
  2. Take t and each v1 value from the X-YCP-Signature header. While a secret is being rotated there can be more than one v1.
  3. Compute HMAC-SHA256 of {t}.{raw body} with your signing secret (the whole whsec_… string, as UTF-8) as the key, as lowercase hex.
  4. Compare it with each v1 value in constant time. At least one must match.
  5. Refuse the request if t is 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
// 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
<?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']
Python
# 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
C#
// 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 id once, and read GET /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.