# M2M Withdrawals

The **M2M Withdrawals** API is the server-to-server interface of Payout API v2 for creating, retrieving and cancelling withdrawals: transfers from your Payout balance to a customer's IBAN. It is secured by two qualified certificates issued by a Qualified Trust Service Provider (QTSP) under the EU eIDAS regulation:

- **QWAC** (Qualified Website Authentication Certificate): establishes a mutually authenticated TLS connection (mTLS).
- **QSEAL** (Qualified Electronic Seal Certificate): signs each payment instruction (detached JWS).

> [!NOTE]
> Importing, approving and renewing certificates is described in [mTLS client certificates](https://developers.payout.tech/guides/certificates.html). This page covers only what is specific to withdrawals: hosts, endpoints, QSEAL signing and error behaviour.

## Endpoints

| Environment | mTLS host |
|---|---|
| **Sandbox** | `https://api-mtls-sandbox.payout.one` |
| **Production** | `https://api-mtls.payout.one` |

| Method | Path | Authentication | Purpose |
|---|---|---|---|
| `POST` | `/api/v2/withdrawals` | mTLS + QSEAL | [Create a withdrawal](https://developers.payout.tech/api/payment.html#create_withdrawal) |
| `GET` | `/api/v2/withdrawals` | mTLS | [List withdrawals](https://developers.payout.tech/api/payment.html#list_withdrawals) |
| `GET` | `/api/v2/withdrawals/:id` | mTLS | [Retrieve a withdrawal](https://developers.payout.tech/api/payment.html#retrieve_withdrawal) |
| `POST` | `/api/v2/withdrawals/:id/cancel` | mTLS + QSEAL | [Cancel a withdrawal](https://developers.payout.tech/api/payment.html#cancel_withdrawal) that has not been processed yet |
| `POST` | `/api/v2/withdrawals/:id/cancel_allowed` | mTLS | [Check whether a withdrawal can be cancelled](https://developers.payout.tech/api/payment.html#withdrawal_cancel_allowed) |

Every request also needs a bearer token from `POST /api/v1/authorize` on the standard host, see [mTLS client certificates](https://developers.payout.tech/guides/certificates.html#setup).

## Prerequisites

Before you can call any v2 withdrawal endpoint, your account needs an `approved` **QWAC** *and* an `approved` **QSEAL** certificate. Both must be issued by a supported QTSP on the [EU Trusted List](https://ec.europa.eu/tools/lotl/eu-lotl.xml), currently **I.CA** and **Disig**. For a certificate from another QTSP on the list, contact [tech@payout.one](mailto:tech@payout.one). Standard eIDAS qualified certificates are enough; no PSD2-specific extensions are required (see [Certificate profiles](https://developers.payout.tech/guides/certificates.html#certificate-profiles)).

The full setup is in [mTLS client certificates](https://developers.payout.tech/guides/certificates.html#setup). In short:

1. **Get the certificates.** Obtain a QWAC and a QSEAL from a QTSP.
2. **Import them.** Import both with `POST /api/v1/mtls/certificates` on the standard host (`sandbox.payout.one` or `app.payout.one`).
3. **Wait for approval.** Payout approves them manually.
4. **Call the API.** Call the v2 endpoints on the mTLS host and present the QWAC in the TLS handshake.

## Signing payment instructions with QSEAL

Every request that **creates or changes** a withdrawal (`POST /api/v2/withdrawals`, `POST /api/v2/withdrawals/:id/cancel`) must be signed with a detached JWS made with the private key of your QSEAL certificate. The other requests (`GET …` and `cancel_allowed`) need only the bearer token and the QWAC, no QSEAL signature.

### Required headers

| Header | Value |
|---|---|
| `Authorization` | `Bearer <TOKEN>` |
| `Content-Type` | `application/json` |
| `Digest` | `SHA-256=<base64(sha256(body))>`, computed over the exact bytes you send |
| `X-JWS-Signature` | `<protected-header>..<signature>` (detached JWS) |
| `Idempotency-Key` | Optional, on create. If a withdrawal with the same key already exists for your account, it is returned with status `200` instead of creating a new one. |

### JWS protected header

The protected header is a base64url-encoded JSON object:

```json
{
  "alg": "PS256",
  "typ": "JOSE+JSON",
  "x5t#S256": "<sha256 thumbprint of QSEAL cert, lowercase hex>",
  "crit": ["sigT", "sigD"],
  "sigT": "2026-05-15T10:00:00Z",
  "sigD": {
    "mId": "http://uri.etsi.org/19182/HttpHeaders",
    "pars": ["digest"]
  }
}
```

The signed payload is the value of the `Digest` header, not the body itself. Accepted algorithms: `PS256`, `RS256` and `ES256`.

### Signing flow (pseudocode)

```text
body          = '{"amount":"100","currency":"EUR",...}'
digest_b64    = base64( sha256(body) )
digest_header = "SHA-256=" + digest_b64

protected_b64 = base64url( JSON.stringify(protected_header) )
payload_b64   = base64url( digest_header )
signing_input = protected_b64 + "." + payload_b64

signature_b64 = base64url( sign(signing_input, qseal_private_key, "PS256") )

x_jws_signature = protected_b64 + ".." + signature_b64
```

### Server-side verification

Payout verifies, for every QSEAL-signed request:

1. The `Digest` header matches the SHA-256 of the received body.
2. The `x5t#S256` in the protected header matches an `approved` QSEAL certificate of your account.
3. The certificate has not expired.
4. `sigT` is within ±5 minutes of the server time (replay protection).
5. The JWS signature is valid for the public key of that certificate.

If any check fails, the response is `403 Forbidden`.

## Example: full request

```http
POST /api/v2/withdrawals HTTP/1.1
Host: api-mtls.payout.one
Authorization: Bearer <TOKEN>
Content-Type: application/json
Digest: SHA-256=wVuNpX91QYPeRl7fKRMbY0JVnKFicMJN9CGZ/ScU9dk=
X-JWS-Signature: <base64url protected header>..<base64url signature>

{
  "amount": "10000",
  "currency": "EUR",
  "external_id": "merchant-tx-2026-05-15-001",
  "iban": "SK3112000000198742637541",
  "customer": {
    "first_name": "Anna",
    "last_name": "Nová",
    "email": "anna.nova@example.com"
  },
  "statement_descriptor": "Platba 2026/05",
  "nonce": "5b6e9c1a-f4a2-4c11-9e56-2f93e1c7a3d0",
  "require_vop": true,
  "additional_attribute": "Invoice 2026/05/017"
}
```

> [!NOTE]
> The `Digest` above is the SHA-256 of this exact body, without a trailing newline. When the request is signed with QSEAL, the HMAC `signature` field of `/api/v1/withdrawals` is **not required**: the QSEAL signature protects the whole body.

## Verification of Payee (VoP)

You can ask Payout to run a **Verification of Payee** check as part of a withdrawal. It confirms that the name you provided matches the account holder the beneficiary bank has on record for the IBAN. There is no separate VoP endpoint: you opt in per withdrawal with the optional `require_vop` field in the create request.

| Field | Type | Required | Description |
|---|---|---|---|
| `require_vop` | boolean | no | Default `false`. When `true`, Payout runs a VoP check on the withdrawal's `iban` and `customer` name and sends the result in a webhook (see below). |
| `additional_attribute` | string | no | Free text returned in the VoP webhook, for example an internal reference or an invoice number. |

Provide the payee's identity in the `customer` object, either as a natural person or as a legal entity:

| Payee | `customer` fields |
|---|---|
| Natural person | `first_name` + `last_name` |
| Legal entity (company) | `name` (company name) + `company_id` (organisation identifier, such as IČO or LEI) |

For a company, send `name` and `company_id` instead of `first_name` / `last_name`:

```json
"customer": {
  "name": "Example Company s. r. o.",
  "company_id": "12345678",
  "email": "billing@example.com"
}
```

The VoP check does **not** block the withdrawal: it runs alongside it and reports its result asynchronously.

> [!NOTE]
> VoP returns a **match outcome**, not an authoritative answer. A `CLOSE_MATCH` does not mean the payment is safe: show the returned `real_name` to your operator and let them confirm or cancel the withdrawal.

### VoP webhook

As soon as the VoP result is known, Payout sends a webhook of type **`withdrawal.vop_result`** to your configured webhook URL. It uses the standard webhook envelope; `data` carries the VoP outcome and the identifiers that match it to the withdrawal:

```json
{
  "type": "withdrawal.vop_result",
  "object": "webhook",
  "data": {
    "match_result": "CLOSE_MATCH",
    "real_name": "Anna Nová-Kováčová",
    "reference_id": "f1c8d4a3-2e90-4a52-9b13-7c8a1d4e5b21",
    "timestamp": "2026-05-20T10:14:33Z",
    "additional_attribute": "Invoice 2026/05/017",
    "withdrawal_id": 90412,
    "external_id": "merchant-tx-2026-05-15-001"
  },
  "external_id": "merchant-tx-2026-05-15-001",
  "nonce": "UzhER2lFOFZCNkNQVmNuNQ",
  "signature": "ab087a4c72388cc2e14fe5c2b278c4aac5b404d4f439f9d761bb660122c05f91"
}
```

Verify `signature` as for other webhooks: `lowercase(hex(SHA-256("external_id|type|nonce|client_secret")))`, with the top-level `external_id`, `type` and `nonce` of the envelope.

`data` fields:

| Field | Type | Description |
|---|---|---|
| `match_result` | enum | One of `MATCH`, `CLOSE_MATCH`, `NO_MATCH`, `CANNOT_VERIFY`. |
| `real_name` | string or null | Set only when `match_result` is `CLOSE_MATCH`: the name the beneficiary bank has on record, so you can show it to your operator for confirmation. |
| `reference_id` | UUID | ID of the check for audit purposes. Quote it in support requests. |
| `timestamp` | ISO 8601 | UTC datetime when the check was processed. |
| `additional_attribute` | string or null | The `additional_attribute` of the withdrawal request, if you sent one. |
| `withdrawal_id` | integer | Payout ID of the withdrawal the check belongs to. |
| `external_id` | string | Your `external_id` from the withdrawal request. |

### Match outcomes

| Outcome | Meaning |
|---|---|
| `MATCH` | Full correspondence between the submitted name and the beneficiary bank's records. |
| `CLOSE_MATCH` | Partial correspondence (typos, missing diacritics, suffix mismatch). `real_name` is returned. |
| `NO_MATCH` | No correspondence — the name does not belong to the IBAN. |
| `CANNOT_VERIFY` | The check could not be completed (beneficiary bank unreachable, IBAN unknown to it, or the bank has opted out of VoP). |

> [!IMPORTANT]
> VoP only tells you whether the name matches the account holder of the IBAN. It is **not** a substitute for AML screening, sanctions list checks or any other due diligence you are required to perform on a payee.

### Testing in sandbox

In sandbox the VoP outcome is determined by the payee `iban`, so you can exercise every branch of your `withdrawal.vop_result` handling. Create a withdrawal with `require_vop: true` to one of these IBANs:

| IBAN | `match_result` |
|---|---|
| `SK5409000000000000000001` | `MATCH` |
| `SK2709000000000000000002` | `CLOSE_MATCH` — `real_name` is the submitted name with first and last name swapped |
| `SK9709000000000000000003` | `NO_MATCH` |
| `SK7009000000000000000004` | `CANNOT_VERIFY` |
| any other IBAN | `CANNOT_VERIFY` |

The webhook arrives a few seconds after the `201` response.

## HTTP error codes

Errors of the token and of the certificate import are described in [mTLS client certificates](https://developers.payout.tech/guides/certificates.html#http-error-codes). The withdrawal endpoints return:

| Code | Meaning |
|---|---|
| `200 OK` | On create: a withdrawal with the same `Idempotency-Key` already exists and is returned. On cancel: also when the withdrawal can no longer be cancelled; the body is then `{"allowed": false, "status": "<current status>"}`. |
| `400 Bad Request` | `iban` is missing, the balance is zero or too low, or the currency is invalid or not enabled for your account. |
| `401 Unauthorized` | The bearer token is missing, invalid or expired. |
| `403 Forbidden` | The QWAC or the QSEAL signature was not accepted (see [Server-side verification](#server-side-verification)), or the withdrawal belongs to another account. |
| `404 Not Found` | The withdrawal does not exist, or the request was not sent to the mTLS host (empty body). |
| `422 Unprocessable Entity` | Validation failed, with `errors` per field (for example a blocked IBAN or an invalid `customer`), or the risk check refused the withdrawal (`Not allowed to proceed.`). |

A TLS handshake is refused when no client certificate is presented or it is not issued by a trusted QTSP.
