> ## Documentation Index
> Fetch the complete documentation index at: https://docs.compago.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Hold Funds

> Hold funds on a saved card, then capture them (in full or in part) or release them within 7 natural days.

A **hold** (also called a pre-authorization) reserves money on a customer's saved card without taking it. The funds leave the customer's available balance immediately, but nothing is charged until you capture the hold. If you never capture it, the money goes back to the customer.

Holds use the same charge endpoint you already know, with one extra field: `holdFunds: true`.

## Overview

Holds are useful whenever you need to know the money is there before you commit:

* **Deposits**: hold a security deposit for equipment or vehicle rentals, then release it when the item comes back intact
* **Bookings and reservations**: hold the amount when a reservation is made, capture it at check-in or check-out
* **Verifying funds before fulfilment**: hold the order total, confirm stock or availability, then capture only if you can actually fulfil it
* **Security holds**: block funds against damages or overages, and give them back when nothing was owed

The typical flow is: block the funds for up to 7 natural days, then either take the money at checkout (all of it, or only part of it) or release it.

<Warning>
  **The window is counted in natural days, not in hours.** The day you place the hold counts as day 1, and the time of day you place it does not change the deadline. A hold placed Monday expires on Sunday, whether you placed it at 10:00 or at 23:59. See [The Hold Window](#the-hold-window) for the exact rule and a worked example.
</Warning>

## Hold Lifecycle

A hold is a payment like any other, with its own status:

| Status | What it means |
| - | - |
| `HELD` | The bank authorized the amount and the funds are reserved. Nothing has been charged yet. This is the only status you can capture or release from. |
| `HOLD_RELEASED` | You released the hold with the release endpoint. The funds were returned to the customer and the payment is final. |
| `HOLD_EXPIRED` | Nobody captured the hold before its window closed, so Compago released it automatically. The funds were returned to the customer and the payment is final. |

The path a hold can take:

1. **Place the hold**: `POST /api/payment-method/{id}/payment` with `holdFunds: true`. On approval the payment becomes `HELD`.
2. Then exactly one of:
   * **Capture**: `POST .../capture` takes the money, all of it or part of it. The payment becomes `CONFIRMED`, exactly like a regular charge. You get **one** capture per hold. See [Capturing a Hold](#capturing-a-hold).
   * **Release**: `POST .../release` gives the money back. The payment becomes `HOLD_RELEASED`.
   * **Expiry**: you do nothing until the window closes. Compago releases the hold and the payment becomes `HOLD_EXPIRED`.

`HOLD_RELEASED` and `HOLD_EXPIRED` are terminal. A released hold cannot be captured, and it cannot be refunded either (there is nothing to refund). To charge the customer after a release, place a new hold or a regular charge.

<Note>
  A hold the bank declines does **not** become a hold. It is recorded as a `CANCELLED` payment, and the API responds with 402. Nothing is reserved on the card.
</Note>

## Prerequisites

Before placing a hold:

* The payment method must have a status of `ACTIVE` (the customer has [saved their card](/payment-method/save-card))
* You must have the payment method `id`
* The card must not be expired or removed by the customer
* Your API key needs the payment method `charge` permission to place a hold, and the `capture` and `release` permissions to finish it

## Placing a Hold

<Note>
  These examples call the production host. While you build, replace `https://api-harmony.compago.com` with `https://demo-api-harmony.compago.com` and use a key from the Demo dashboard. See [Environments](/get-started/environments).
</Note>

### Endpoint

```
POST /api/payment-method/{id}/payment
```

**API reference:** [`POST /payment-method/{id}/payment`](/api-reference/payment-method/charge)

### Authentication

Include your API key in the request headers:

```
x-api-key: YOUR_API_KEY
Content-Type: application/json
```

### Path Parameters

<ParamField path="id" type="string" required>
  The unique identifier of the payment method to hold funds on (UUID).
</ParamField>

### Request Body

```json theme={null}
{
  "amount": 3500,
  "currency": "MXN",
  "description": "Depósito en garantía - Renta de equipo #R-4821",
  "holdFunds": true
}
```

### Field Descriptions

| Field | Type | Required | Description |
| - | - | - | - |
| `amount` | number | Yes | Amount to hold in MXN. |
| `currency` | string | No | Currency code. Defaults to `"MXN"`. |
| `description` | string | No | Description of the operation. |
| `holdFunds` | boolean | No | When `true`, the funds are only held and nothing is captured until you call the capture endpoint. Defaults to `false`, which charges the card immediately. |
| `externalId` | string | No | Your idempotency key for this hold, up to 255 characters, unique per organization. Repeating a request with the same value returns the original hold instead of placing a second one. See [Idempotency](#idempotency-externalid). |

### Response

```json theme={null}
{
  "id": "11111111-2222-3333-4444-555555555555",
  "status": "HELD",
  "amount": 3500,
  "heldAmount": 3500,
  "currency": "MXN",
  "operationCode": 48219306571,
  "createdAt": "2025-03-04T16:20:11.482Z",
  "externalId": null,
  "heldAt": "2025-03-04T16:20:12.907Z",
  "capturedAt": null,
  "holdReleasedAt": null,
  "holdExpiresAt": "2025-03-11T05:00:00.000Z",
  "holdExpired": false,
  "holdDaysRemaining": 7
}
```

The `heldAt` above is 2025-03-04 10:20 in Mexico City, so the placement day is Tuesday 4 March. Six calendar days later is Monday 10 March, and the window closes at 23:00 that day, which is `2025-03-11T05:00:00.000Z` in UTC.

| Field | Type | Description |
| - | - | - |
| `id` | string | Unique identifier of the payment. Use it for capture, release, and lookups. |
| `status` | string | `HELD` when the bank approved the hold. |
| `amount` | number | The money that moved, or that will move. While the payment is `HELD` it equals `heldAmount`. After a [partial capture](#capturing-part-of-a-hold) it is the **captured** amount. |
| `heldAmount` | number \| null | The amount the bank authorized when the hold was placed. It is never rewritten, so it stays the record of what was blocked on the card even after a partial capture. `null` on payments that were never held. |
| `currency` | string | Currency code. |
| `operationCode` | number | Operation reference for the payment. |
| `createdAt` | string | When the payment record was created (ISO 8601). |
| `externalId` | string \| null | The idempotency key you sent with the request, echoed back so you can map our payment `id` to your own order reference. `null` when you sent none. |
| `heldAt` | string \| null | When the funds were reserved on the card. `null` for payments that were never held. |
| `capturedAt` | string \| null | When the hold was captured. `null` until you capture it. |
| `holdReleasedAt` | string \| null | When the hold was released, whether by you or by the automatic expiry. `null` while the hold is live. |
| `holdExpiresAt` | string \| null | 23:00 America/Mexico\_City on the 7th natural day, counting the day of `heldAt` as day 1. `null` for payments that were never held. |
| `holdExpired` | boolean | `true` once `holdExpiresAt` has passed. `false` for payments that were never held. |
| `holdDaysRemaining` | number \| null | Natural days on which you can still capture, including today when today's 23:00 cutoff has not passed yet. It is `7` for a hold placed before 23:00 on its placement day, `6` for one placed at or after 23:00 (that placement day is already spent), `1` through the last day, and `0` from 23:00 on the last day. `null` unless the payment is currently `HELD`. |

<Note>
  These hold fields are present on every payment response for a saved card, including regular charges and lookups. On a regular charge they are `null` (and `holdExpired` is `false`), so you can read the same shape everywhere.
</Note>

### Code Examples

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api-harmony.compago.com/api/payment-method/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/payment \
    -H "x-api-key: YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "amount": 3500,
      "currency": "MXN",
      "description": "Depósito en garantía - Renta de equipo #R-4821",
      "holdFunds": true
    }'
  ```

  ```javascript Node.js theme={null}
  const paymentMethodId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';

  const response = await fetch(
    `https://api-harmony.compago.com/api/payment-method/${paymentMethodId}/payment`,
    {
      method: 'POST',
      headers: {
        'x-api-key': process.env.COMPAGO_API_KEY,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        amount: 3500,
        currency: 'MXN',
        description: 'Depósito en garantía - Renta de equipo #R-4821',
        holdFunds: true
      })
    }
  );

  const hold = await response.json();
  console.log('Payment ID:', hold.id);
  console.log('Status:', hold.status);
  console.log('Expires at:', hold.holdExpiresAt);
  ```

  ```python Python theme={null}
  import requests
  import os

  payment_method_id = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'

  response = requests.post(
      f'https://api-harmony.compago.com/api/payment-method/{payment_method_id}/payment',
      headers={
          'x-api-key': os.environ['COMPAGO_API_KEY'],
          'Content-Type': 'application/json'
      },
      json={
          'amount': 3500,
          'currency': 'MXN',
          'description': 'Depósito en garantía - Renta de equipo #R-4821',
          'holdFunds': True
      }
  )

  hold = response.json()
  print('Payment ID:', hold['id'])
  print('Status:', hold['status'])
  print('Expires at:', hold['holdExpiresAt'])
  ```

  ```php PHP theme={null}
  <?php
  $paymentMethodId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';

  $ch = curl_init("https://api-harmony.compago.com/api/payment-method/{$paymentMethodId}/payment");
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  curl_setopt($ch, CURLOPT_POST, true);
  curl_setopt($ch, CURLOPT_HTTPHEADER, [
      'x-api-key: ' . getenv('COMPAGO_API_KEY'),
      'Content-Type: application/json'
  ]);
  curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
      'amount' => 3500,
      'currency' => 'MXN',
      'description' => 'Depósito en garantía - Renta de equipo #R-4821',
      'holdFunds' => true
  ]));

  $response = curl_exec($ch);
  $hold = json_decode($response, true);
  curl_close($ch);

  echo 'Payment ID: ' . $hold['id'] . PHP_EOL;
  echo 'Status: ' . $hold['status'] . PHP_EOL;
  echo 'Expires at: ' . $hold['holdExpiresAt'] . PHP_EOL;
  ?>
  ```

  ```ruby Ruby theme={null}
  require 'net/http'
  require 'json'
  require 'uri'

  payment_method_id = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
  uri = URI("https://api-harmony.compago.com/api/payment-method/#{payment_method_id}/payment")
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true

  request = Net::HTTP::Post.new(uri)
  request['x-api-key'] = ENV['COMPAGO_API_KEY']
  request['Content-Type'] = 'application/json'
  request.body = {
    amount: 3500,
    currency: 'MXN',
    description: 'Depósito en garantía - Renta de equipo #R-4821',
    holdFunds: true
  }.to_json

  response = http.request(request)
  hold = JSON.parse(response.body)

  puts "Payment ID: #{hold['id']}"
  puts "Status: #{hold['status']}"
  puts "Expires at: #{hold['holdExpiresAt']}"
  ```
</CodeGroup>

## Capturing a Hold

Capture turns the held funds into a real charge. Do this at checkout, at check-in, when the order ships, or whenever you have decided to keep the money.

You can capture the whole held amount or only part of it. Capturing in full is the default: send no request body and the full held amount is taken.

### Endpoint

```
POST /api/payment-method/{id}/payment/{paymentId}/capture
```

**API reference:** [`POST /payment-method/{id}/payment/{paymentId}/capture`](/api-reference/payment-method/capture)

<Warning>
  **There is exactly one capture per hold, and Compago never releases the difference.** These are the two rules that catch integrators out, so read them before you capture anything:

  1. **One capture, ever.** The moment a capture succeeds, the hold is finished. You cannot capture $200 MXN today and the remaining $1,000 MXN tomorrow: a second capture is refused with 409 `PAYMENT_NOT_HELD` and there is no way back to the rest of the money. If you might need more later, capture the full amount, or agree a new charge with the customer. Multiple captures against one authorization work on some other processors. They do not work here.
  2. **Compago issues no release for the remainder.** Capturing $200 MXN of a $1,200 MXN hold does not free the other \$1,000 MXN on our side: we send the bank nothing for it. The customer's issuing bank frees it on its own schedule, usually within a few days, and neither you nor Compago can make that happen sooner. Tell the customer this when you capture less than you held, especially on large deposits.
</Warning>

<Warning>
  **Capture stops at 23:00 on the last day.** From 23:00 America/Mexico\_City on the last day of the window, capture is refused with 409 `HOLD_WINDOW_CLOSED`, even if the automatic release has not physically run yet. Compago refuses rather than let a capture be in flight while the bank is closing its day at 23:30. Your last minute to capture is **22:59 on the last day**. See [The Hold Window](#the-hold-window).
</Warning>

### Path Parameters

<ParamField path="id" type="string" required>
  The unique identifier of the payment method (UUID).
</ParamField>

<ParamField path="paymentId" type="string" required>
  The unique identifier of the held payment to capture (UUID).
</ParamField>

### Request Body

The body is **optional**. Send no body at all and the hold is captured in full, which is what an integration that never asks for a partial capture should keep doing.

To capture part of the hold, send the amount you want to take:

```json theme={null}
{
  "amount": 200
}
```

| Field | Type | Required | Description |
| - | - | - | - |
| `amount` | number | No | How much to capture, in MXN. Must be greater than `0` and no greater than the payment's `heldAmount`. Omit it, or omit the body, to capture the hold in full. |

The bounds are simply `0 < amount <= heldAmount`:

* **Capturing less than you held is allowed**, and it is the point of the field. The customer is charged less than the amount that was blocked.
* **Capturing more than you held is impossible.** It is refused with 400 `CAPTURE_AMOUNT_EXCEEDS_HOLD` before the bank is contacted. To take more, place a separate charge.
* **Sending `amount` equal to `heldAmount` is identical to sending no body.**
* `0`, a negative number, or a non-numeric value is refused with 400 `INVALID_CAPTURE_AMOUNT`.
* **The 1 MXN minimum that applies to a charge does not apply to a capture.** A capture is a settlement instruction against an authorization that already cleared that floor, so small captures are accepted.

### Capturing Part of a Hold

A partial capture rewrites the payment's `amount` to the amount you captured, and `heldAmount` keeps the amount the bank originally authorized. Hold $1,200 MXN, capture $200 MXN, and the payment reports:

```json theme={null}
{
  "id": "11111111-2222-3333-4444-555555555555",
  "status": "CONFIRMED",
  "amount": 200,
  "heldAmount": 1200,
  "currency": "MXN",
  "capturedAt": "2025-03-06T09:14:53.220Z"
}
```

Read the two fields like this:

| Field | What it answers |
| - | - |
| `amount` | **The money that moved.** It is what the customer was charged, what your fees are calculated on, what is paid out to you, and what a later refund returns. |
| `heldAmount` | **What the bank authorized.** It is stamped when the hold is placed and never rewritten, so it stays the record of what was blocked on the card. |

The rule, in one line: **the authorization is `heldAmount`, the money that moved is `amount`, never the reverse.**

<Warning>
  **`amount` changes when you capture part of a hold.** It is $1,200 MXN while the payment is `HELD` and $200 MXN after a \$200 MXN capture. Anything you recorded before the capture, an exported report, a notification you already sent, your own copy of the order, will disagree with the payment afterwards. Show **both** numbers on a partially captured payment, never just one.
</Warning>

<Note>
  `heldAmount` is returned by every saved-card payment endpoint (the hold itself, capture, release, list, and detail). It is also on the payments list and payment detail in the Compago dashboard, and in the payments CSV export as the **Monto retenido** column. It is `null` on any payment that was never held.
</Note>

### Response

```json theme={null}
{
  "id": "11111111-2222-3333-4444-555555555555",
  "status": "CONFIRMED",
  "amount": 3500,
  "heldAmount": 3500,
  "currency": "MXN",
  "operationCode": 48219306571,
  "createdAt": "2025-03-04T16:20:11.482Z",
  "externalId": null,
  "heldAt": "2025-03-04T16:20:12.907Z",
  "capturedAt": "2025-03-06T09:14:53.220Z",
  "holdReleasedAt": null,
  "holdExpiresAt": "2025-03-11T05:00:00.000Z",
  "holdExpired": false,
  "holdDaysRemaining": null
}
```

This is a capture in full, so `amount` and `heldAmount` agree. A captured hold ends up as a `CONFIRMED` payment, indistinguishable from a regular charge apart from its `heldAmount` and its `heldAt` and `capturedAt` timestamps. `holdDaysRemaining` becomes `null` because the payment is no longer `HELD`.

<Note>
  **A captured hold's refund window runs from the day it was captured, not from the day it was placed.** A hold placed on Monday and captured on Sunday is refundable through Sunday's bank cutoff, six days later than you might expect. Refunding a partially captured payment returns the **captured** amount (`amount`), not the held one. See [Process Refunds](/payment-method/refund).
</Note>

### Code Examples

<CodeGroup>
  ```bash cURL theme={null}
  # Capture in full: no request body.
  curl -X POST https://api-harmony.compago.com/api/payment-method/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/payment/11111111-2222-3333-4444-555555555555/capture \
    -H "x-api-key: YOUR_API_KEY"

  # Capture part of it: 200 of the 1200 that were held.
  curl -X POST https://api-harmony.compago.com/api/payment-method/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/payment/11111111-2222-3333-4444-555555555555/capture \
    -H "x-api-key: YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{ "amount": 200 }'
  ```

  ```javascript Node.js theme={null}
  // Leave `amount` undefined to capture the full held amount.
  async function captureHold(paymentMethodId, paymentId, amount) {
    const isPartial = amount !== undefined;

    const response = await fetch(
      `https://api-harmony.compago.com/api/payment-method/${paymentMethodId}/payment/${paymentId}/capture`,
      {
        method: 'POST',
        headers: {
          'x-api-key': process.env.COMPAGO_API_KEY,
          ...(isPartial ? { 'Content-Type': 'application/json' } : {})
        },
        ...(isPartial ? { body: JSON.stringify({ amount }) } : {})
      }
    );

    if (response.status === 400) {
      console.error('The amount was not a positive number, or it was more than the held amount.');
    } else if (response.status === 409) {
      console.error('Not capturable: the window closed at 23:00, the hold has no fee channel, or the hold was already captured, released, or expired.');
    } else if (response.status === 402) {
      console.error('The bank declined the capture. The funds may already be gone from the card.');
    }

    const payment = await response.json();
    console.log('Captured', payment.amount, 'of', payment.heldAmount, 'at', payment.capturedAt);
    return payment;
  }

  await captureHold(paymentMethodId, paymentId);       // the full held amount
  await captureHold(paymentMethodId, paymentId, 200);  // only 200 of it
  ```

  ```python Python theme={null}
  def capture_hold(payment_method_id, payment_id, amount=None):
      response = requests.post(
          f'https://api-harmony.compago.com/api/payment-method/{payment_method_id}/payment/{payment_id}/capture',
          headers={'x-api-key': os.environ['COMPAGO_API_KEY']},
          # No body at all captures the full held amount.
          json=None if amount is None else {'amount': amount}
      )

      if response.status_code == 400:
          print('The amount was not a positive number, or it was more than the held amount.')
      elif response.status_code == 409:
          print('Not capturable: the window closed at 23:00, the hold has no fee channel, or the hold was already captured, released, or expired.')
      elif response.status_code == 402:
          print('The bank declined the capture. The funds may already be gone from the card.')
      else:
          payment = response.json()
          print('Captured', payment['amount'], 'of', payment['heldAmount'], 'at', payment['capturedAt'])

  capture_hold(payment_method_id, payment_id)       # the full held amount
  capture_hold(payment_method_id, payment_id, 200)  # only 200 of it
  ```

  ```php PHP theme={null}
  <?php
  function captureHold($paymentMethodId, $paymentId, $amount = null) {
      $ch = curl_init("https://api-harmony.compago.com/api/payment-method/{$paymentMethodId}/payment/{$paymentId}/capture");
      curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
      curl_setopt($ch, CURLOPT_POST, true);

      $headers = ['x-api-key: ' . getenv('COMPAGO_API_KEY')];

      // Send a body only for a partial capture. No body means the full held amount.
      if ($amount !== null) {
          $headers[] = 'Content-Type: application/json';
          curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['amount' => $amount]));
      }

      curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

      $response = curl_exec($ch);
      $payment = json_decode($response, true);
      curl_close($ch);

      echo 'Captured ' . $payment['amount'] . ' of ' . $payment['heldAmount'] . PHP_EOL;

      return $payment;
  }

  captureHold($paymentMethodId, $paymentId);       // the full held amount
  captureHold($paymentMethodId, $paymentId, 200);  // only 200 of it
  ?>
  ```

  ```ruby Ruby theme={null}
  # Pass no amount to capture the full held amount.
  def capture_hold(payment_method_id, payment_id, amount = nil)
    uri = URI("https://api-harmony.compago.com/api/payment-method/#{payment_method_id}/payment/#{payment_id}/capture")

    request = Net::HTTP::Post.new(uri)
    request['x-api-key'] = ENV['COMPAGO_API_KEY']

    # Send a body only for a partial capture.
    unless amount.nil?
      request['Content-Type'] = 'application/json'
      request.body = { amount: amount }.to_json
    end

    response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }
    payment = JSON.parse(response.body)

    puts "Captured #{payment['amount']} of #{payment['heldAmount']}"

    payment
  end

  capture_hold(payment_method_id, payment_id)       # the full held amount
  capture_hold(payment_method_id, payment_id, 200)  # only 200 of it
  ```
</CodeGroup>

<Note>
  Capture is safe to call twice. The second call does not reach the bank: it sees the payment is no longer `HELD` and answers 409. That means a 409 is not proof of failure, it can also mean your first call already succeeded. Read the payment back to find out which.
</Note>

## Releasing a Hold

Release gives the money back before the window closes. Use it as soon as you know you will not be charging the customer: the funds stay unavailable to them until the release reaches their bank.

<Note>
  **Release has no time cutoff.** Unlike capture, release is accepted at any time while the payment is `HELD`, including after 23:00 on the last day and after the window has closed. Releasing an expired hold still works: you are giving money back, not taking it, so there is no race with the bank to avoid. A hold you release yourself ends as `HOLD_RELEASED`, even if the automatic sweep would have reached it minutes later.
</Note>

### Endpoint

```
POST /api/payment-method/{id}/payment/{paymentId}/release
```

**API reference:** [`POST /payment-method/{id}/payment/{paymentId}/release`](/api-reference/payment-method/release)

### Path Parameters

<ParamField path="id" type="string" required>
  The unique identifier of the payment method (UUID).
</ParamField>

<ParamField path="paymentId" type="string" required>
  The unique identifier of the held payment to release (UUID).
</ParamField>

### Response

```json theme={null}
{
  "id": "11111111-2222-3333-4444-555555555555",
  "status": "HOLD_RELEASED",
  "amount": 3500,
  "heldAmount": 3500,
  "currency": "MXN",
  "operationCode": 48219306571,
  "createdAt": "2025-03-04T16:20:11.482Z",
  "externalId": null,
  "heldAt": "2025-03-04T16:20:12.907Z",
  "capturedAt": null,
  "holdReleasedAt": "2025-03-05T11:02:38.114Z",
  "holdExpiresAt": "2025-03-11T05:00:00.000Z",
  "holdExpired": false,
  "holdDaysRemaining": null
}
```

### Code Examples

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api-harmony.compago.com/api/payment-method/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/payment/11111111-2222-3333-4444-555555555555/release \
    -H "x-api-key: YOUR_API_KEY"
  ```

  ```javascript Node.js theme={null}
  const response = await fetch(
    `https://api-harmony.compago.com/api/payment-method/${paymentMethodId}/payment/${paymentId}/release`,
    {
      method: 'POST',
      headers: { 'x-api-key': process.env.COMPAGO_API_KEY }
    }
  );

  const payment = await response.json();
  console.log('Status:', payment.status, 'Released at:', payment.holdReleasedAt);
  ```

  ```python Python theme={null}
  response = requests.post(
      f'https://api-harmony.compago.com/api/payment-method/{payment_method_id}/payment/{payment_id}/release',
      headers={'x-api-key': os.environ['COMPAGO_API_KEY']}
  )

  payment = response.json()
  print('Status:', payment['status'], 'Released at:', payment['holdReleasedAt'])
  ```
</CodeGroup>

<Note>
  A release never checks the card. Even if the customer removed the card or it expired since the hold was placed, the money still goes back.
</Note>

<Warning>
  **The customer's bank decides when the money reappears.** Compago reverses the authorization immediately, but issuers can take a few business days to restore the available balance. Tell customers this upfront, especially on large deposits.
</Warning>

## Holds From a Saved-Card Link

A hold does not have to start from a card you already have. A [card-saving link](/payment-method/save-card) created with `collectFunds: "HOLD"` blocks the money at the moment the customer enters their card, so one link both saves the card and holds the amount, instead of saving the card first and calling this endpoint afterwards.

What the link produces is an ordinary held payment, so everything on this page applies to it unchanged:

* It is `HELD`, with `heldAt`, `holdExpiresAt`, and the same 7 natural day window.
* It is captured through the same capture endpoint, **partial capture included**.
* It is released through the same release endpoint, and it expires the same way if nobody acts.

The amount held is the `amount` you set on the link, which is the number the customer saw on the checkout page. Capturing less than that is fine, the customer simply ends up charged less than they were shown. Capturing more is impossible.

To find the payment the link created, list the payment method's payments and filter by status:

```
GET /api/payment-method/{id}/payment?status=HELD
```

See [Collecting Funds With the Link](/payment-method/save-card#collecting-funds-with-the-link) for how to create one.

## Checking Whether a Hold Is Still Live

Both payment lookups return the hold fields, so you never have to keep the state yourself:

```
GET /api/payment-method/{id}/payment/{paymentId}
GET /api/payment-method/{id}/payment?status=HELD
```

**API reference:** [`GET /payment-method/{id}/payment/{paymentId}`](/api-reference/payment-method/get-payment) and [`GET /payment-method/{id}/payment`](/api-reference/payment-method/list-payments)

A hold is live, and therefore capturable, when all three of these are true:

* `status` is `HELD`
* `holdExpired` is `false`
* `holdReleasedAt` is `null`

```javascript theme={null}
function isHoldLive(payment) {
  return payment.status === 'HELD' && !payment.holdExpired && payment.holdReleasedAt === null;
}
```

Use `holdDaysRemaining` to drive reminders, for example a warning when it drops to `1` so an operator can decide before the window closes. Remember that `1` means "today", not "another 24 hours": an operator who sees `1` at 22:00 has 59 minutes left. Use `holdExpiresAt` when you need the exact deadline instead of whole days.

## The Hold Window

A hold lives for **7 natural days**. Banks count days, not elapsed hours, and Compago counts them the same way:

* **The day you place the hold is day 1.** The clock time at which you place it is irrelevant to when it dies.
* The hold dies at the **end of the 7th natural day**. Place it on a Monday and the last day you can capture is **Sunday**.
* The bank closes its day at **23:30** local time (America/Mexico\_City). Compago closes the hold at **23:00**, half an hour earlier, so that a capture is never in flight while the bank is dropping the authorization.
* Your **last minute to capture is 22:59 on the last day**.

Compago publishes the deadline as `holdExpiresAt` on every response, so you never have to compute it yourself.

### Worked Example: a Hold Placed on a Monday

Three holds placed on the same Monday, almost fourteen hours apart, expire at **exactly the same instant**:

| You place the hold | `heldAt` (UTC) | `holdExpiresAt` | Last minute to capture |
| - | - | - | - |
| Monday 3 March, **10:00** Mexico City | `2025-03-03T16:00:00.000Z` | `2025-03-10T05:00:00.000Z` (Sunday 9 March, 23:00 Mexico City) | Sunday 9 March, 22:59 |
| Monday 3 March, **23:29** Mexico City | `2025-03-04T05:29:00.000Z` | `2025-03-10T05:00:00.000Z` (Sunday 9 March, 23:00 Mexico City) | Sunday 9 March, 22:59 |
| Monday 3 March, **23:59** Mexico City | `2025-03-04T05:59:00.000Z` | `2025-03-10T05:00:00.000Z` (Sunday 9 March, 23:00 Mexico City) | Sunday 9 March, 22:59 |

**The time of day makes no difference to the deadline.** Every instant of the placement day belongs to the same cohort: a hold placed at 00:00 and one placed at 23:59 on the same day expire together. 10:00, 23:29 and 23:59 are all the same Monday, so all three holds run out on the same Sunday, at the same 23:00.

This is deliberate. The window is standardized on whole natural days, the unit the banks themselves count in, and a hold placed late in the day gets less usable time out of its first day. That is the rule rather than an accident of it. The practical consequence is what you should build around: a hold placed at 23:59 reports `holdDaysRemaining: 6` from the moment it is placed, because its own placement day is already spent, while the one placed at 10:00 reports `7`.

<Warning>
  **One minute of wall clock across local midnight moves the deadline by a whole day.** A hold placed Monday 3 March at 23:59 dies on Sunday 9 March. A hold placed one minute later, Tuesday 4 March at 00:00, dies on Monday 10 March, a full day further out. The boundary that matters is midnight in America/Mexico\_City, not the hour you happen to call the API, so a job that runs "late at night" can produce holds a day apart in length depending on which side of midnight each request lands. If the length of the window matters to your process, pin the placement to a known hour of the day rather than to the end of one.
</Warning>

Counting the days out:

| Day | Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday |
| - | - | - | - | - | - | - | - |
| Day number | **1** (placed) | 2 | 3 | 4 | 5 | 6 | **7** (last day, closes 23:00) |

### Automatic Release

If nobody captures the hold, Compago releases it automatically and the payment becomes `HOLD_EXPIRED`. The sweep that does this runs **twice each night, at 23:00 and again at 23:15 America/Mexico\_City**. Each run releases every hold whose window has already closed.

The 23:15 run is a catch-up: if the 23:00 run is slow or fails, it picks up whatever was left, and it still lands before the bank closes its day at 23:30. When the 23:00 run did its work, the second run finds nothing to do and changes nothing.

Neither run changes what you can do. Capture is still refused from 23:00 on the last day, and 22:59 on the last day is still your last minute to capture. The second run gets the customer's money back sooner when the first one stumbles, it does not extend anyone's window.

<Warning>
  **Do not wait for the sweep to decide whether you can capture.** From 23:00 on the last day, capture is refused with 409 `HOLD_WINDOW_CLOSED` whether or not the sweep has physically reached your payment yet. The refusal is a rule, not a race: Compago will not send a capture into the bank's 23:30 cutoff. Plan to capture during the day, not in the last half hour.
</Warning>

<Warning>
  **Card issuers may drop a hold on their own before the 7 natural days are up.** This is common on debit cards, where some banks release reservations after a few days regardless of what the merchant intended. When that happens the capture is declined with 402 and the funds were already freed. There is nothing to recover: the payment stays `HELD` on Compago's side, and your options are to release it explicitly or to place a new charge with the customer's agreement. For long windows on debit cards, capture as early as you reasonably can.
</Warning>

<Note>
  A declined capture leaves the payment `HELD`. You can retry the capture, release the hold yourself, or leave it to the nightly sweep.
</Note>

## Idempotency (`externalId`)

`POST /api/payment-method/{id}/payment` accepts an optional `externalId`: your own key for this operation, up to 255 characters, unique across all payments in your organization. **It is the supported way to make this endpoint safe to retry, and we recommend sending one on every hold.**

Without it, a retry after a timeout places a **second hold** on the customer's card for the full amount again. One blind retry of a $3,500 MXN deposit leaves $7,000 MXN of the customer's money blocked. With it, the retry returns the hold you already placed.

```json theme={null}
{
  "amount": 3500,
  "currency": "MXN",
  "description": "Depósito en garantía - Renta de equipo #R-4821",
  "holdFunds": true,
  "externalId": "deposit-R-4821"
}
```

<Warning>
  **This is not the payment method `externalId`.** They are two different fields, one screen apart in these docs:

  * The **payment method `externalId`**, sent to `POST /api/payment-method` and documented in [Manage Payment Methods](/payment-method/manage), is your reference for the **saved card or customer**.
  * The **payment `externalId`**, sent to `POST /api/payment-method/{id}/payment` and documented here, is your **idempotency key for one hold or charge**.

  They live in separate namespaces, so the same string may safely be used for both. They are never compared with each other.
</Warning>

### Replay Behaviour

| Situation | What happens |
| - | - |
| You reuse an `externalId` with the same payment method, amount and currency | The **original** payment is returned. No second hold is placed and the bank is not contacted again. The HTTP status is the one the original attempt produced. |
| You reuse an `externalId` whose original was **declined** | The decline is returned again, forever. The key is spent. |
| You reuse an `externalId` with a different payment method, amount or currency | 409 `EXTERNAL_ID_MISMATCH`. The message names the payment the key already identifies. |
| The original request is still in flight | 409 `EXTERNAL_ID_CONFLICT`. Retry in a moment: the first request had not finished writing yet. |
| You send no `externalId`, or an empty string | No idempotency. Every request creates a new payment. |

<Note>
  **A partially captured hold still replays correctly.** The replay compares the `amount` you send against the amount that was **authorized** (`heldAmount`), not against the payment's current `amount`. Retrying your original $1,200 MXN request after capturing $200 MXN of it returns the original payment rather than a 409 mismatch, so keep sending the amount you sent the first time.
</Note>

<Warning>
  **A declined attempt spends the `externalId`.** This surprises people, so it is worth stating plainly: if a hold is declined by the bank, replaying the same `externalId` returns that decline again, it does not try the card a second time. **To retry a declined hold you must send a new `externalId`**, for example `deposit-R-4821-2`.

  This is deliberate. An idempotency key names one attempt, not one intention. The endpoint cannot tell "my HTTP client retried" from "my operator pressed the button again", and freeing the key on a decline would let a duplicate retry fire a second authorization at the issuer, which feeds their fraud heuristics and can produce a genuine double hold when the bank actually approved a request we recorded as declined.
</Warning>

<Note>
  Holds and regular charges share one `externalId` namespace, because both are created by the same endpoint. `order-1` cannot exist as a hold and also as a charge. Replaying a key with a different `holdFunds` value than the original returns the original payment rather than an error, so keep the flag stable across your retries.
</Note>

The key is echoed back as `externalId` on every payment response for a saved card (the hold itself, capture, release, refund, list and detail), so you can reconcile our payment `id` against your own order reference without keeping a mapping table.

### Retrying Safely Without a Key

If you cannot send an `externalId`, a timeout or a dropped connection is **unknown**, not failed. The bank may well have approved the hold your client never saw the response for. The safe sequence is:

<Steps>
  <Step title="Do not retry blindly">
    Treat any network timeout, gateway error, or aborted request as an unknown outcome. Never send the same hold again just because the first call did not return.
  </Step>

  <Step title="Look for an existing hold">
    Call `GET /api/payment-method/{id}/payment?status=HELD` and check for a `HELD` payment matching the amount you sent, created around the time of your request.

    ```javascript theme={null}
    const response = await fetch(
      `https://api-harmony.compago.com/api/payment-method/${paymentMethodId}/payment?status=HELD`,
      { headers: { 'x-api-key': process.env.COMPAGO_API_KEY } }
    );

    const { items } = await response.json();
    const existing = items.find(
      p => p.amount === 3500 && new Date(p.heldAt) >= requestSentAt
    );
    ```
  </Step>

  <Step title="Reuse it, or retry once">
    If a matching hold exists, store its `id` and carry on: the operation succeeded. Only if nothing matches should you send the hold again.
  </Step>

  <Step title="Release duplicates immediately">
    If you find more than one hold for the same operation, release the extras with the release endpoint. Do not leave a duplicate to expire on its own: that leaves the customer's money blocked for the rest of the window for nothing, and it is the most common complaint holds generate.
  </Step>
</Steps>

Capture and release do not have this problem. Both are keyed on a specific `paymentId` and only act on a payment that is still `HELD`, so a repeat call answers 409 instead of touching the card a second time. Neither endpoint accepts an `externalId`.

## Error Handling

The API answers with the HTTP status and a plain text message. The error codes below name each failure so you can match a response with its cause and with the [API reference](/api-reference/payment-method/charge).

### Placing a Hold

| Status Code | Error code | Cause and solution |
| - | - | - |
| 400 | | Payment method is not `ACTIVE`. Only an `ACTIVE` payment method can be used. |
| 400 | | The payment method has no saved card, or the customer removed it. Have the customer save a card again. |
| 400 | `CARD_EXPIRED` | The saved card has expired. Create a new payment method and have the customer save an updated card. |
| 400 | | The saved card has no channel assigned, so no hold can be placed on it. Contact Compago support. |
| 401 | | Unauthorized. Check your API key is valid and included in headers. |
| 402 | `HOLD_DECLINED` | The bank declined the hold. Nothing was reserved, and the payment is recorded as `CANCELLED`. Ask the customer for another card. |
| 404 | | Payment method not found. Verify the payment method ID is correct. |
| 409 | `HOLD_UNAVAILABLE` | The payment is no longer available to hold, typically because a concurrent request already acted on it. Read the payment back before retrying. |
| 409 | `EXTERNAL_ID_MISMATCH` | The `externalId` you sent already identifies a payment with a different payment method, amount, or currency. The message names that payment. Use a different `externalId` for a different operation. |
| 409 | `EXTERNAL_ID_CONFLICT` | The `externalId` is in use by another request that has not finished yet. Retry in a moment: nothing was sent to the bank. |

### Capturing

| Status Code | Error code | Cause and solution |
| - | - | - |
| 400 | | The payment does not belong to this payment method, or it is not a saved-card payment. Check the two IDs. |
| 400 | `INVALID_CAPTURE_AMOUNT` | The `amount` in the body was not a positive number: `0`, a negative number, or not a number at all. Send a number greater than `0`, or send no body to capture in full. Nothing was sent to the bank. |
| 400 | `CAPTURE_AMOUNT_EXCEEDS_HOLD` | The `amount` you asked for is larger than the payment's `heldAmount`, which the message names. You can never capture more than you held. Capture up to the held amount, and place a separate charge for anything beyond it. Nothing was sent to the bank. |
| 401 | | Unauthorized. Check your API key is valid and included in headers. |
| 402 | `CAPTURE_DECLINED` | The bank declined the capture. The payment stays `HELD` at its full held amount. Retry, release it, or place a new charge. |
| 404 | | Payment method or payment not found. Verify both IDs. |
| 409 | `HOLD_WINDOW_CLOSED` | You called capture at or after 23:00 on the last day of the window. The payment is still `HELD`, but Compago refuses rather than race the bank's 23:30 cutoff, even if the automatic release has not run yet. The funds are on their way back to the customer. Place a new charge if the customer still owes you. |
| 409 | `HOLD_CHANNEL_UNKNOWN` | Compago could not resolve the fee channel for this hold, so it cannot be captured. The payment stays `HELD` and nothing was sent to the bank. Release the hold and contact Compago support. |
| 409 | `PAYMENT_NOT_HELD` | The payment is not `HELD`. It was already captured (remember there is only one capture per hold), already released, or the nightly sweep expired it. Read the payment back to see which. |

### Releasing

| Status Code | Error code | Cause and solution |
| - | - | - |
| 400 | | The payment does not belong to this payment method, or it is not a saved-card payment. Check the two IDs. |
| 401 | | Unauthorized. Check your API key is valid and included in headers. |
| 404 | | Payment method or payment not found. Verify both IDs. |
| 409 | `PAYMENT_NOT_HELD` | The payment is not `HELD`, so there is nothing to release. |
| 502 | `RELEASE_FAILED` | The release could not be completed with the bank. The payment stays `HELD` and the nightly sweep will keep retrying it, at 23:00 and again at 23:15. You can retry the release yourself, at any time: release has no 23:00 cutoff. |

## Best Practices

<AccordionGroup>
  <Accordion title="Hold Only What You Need">
    The held amount is the ceiling on what you can capture: you can take less, never more. But taking less does not hand the difference back promptly, because Compago issues no release for it and the customer's issuer frees it on its own schedule. Padding a deposit "just in case" blocks money the customer cannot use. Hold the real figure, and when the final amount turns out to be far lower than the hold, consider releasing the hold and placing a charge for the correct amount instead of capturing a small part of a large one.
  </Accordion>

  <Accordion title="Release as Soon as the Answer Is No">
    The moment a booking is cancelled or a rental comes back clean, release the hold. Waiting for the automatic expiry keeps the customer's money blocked for up to 7 natural days and generates support tickets you could have avoided with one API call.
  </Accordion>

  <Accordion title="Store the Payment ID Immediately">
    Persist the `id` from the hold response before you do anything else. It is the only handle to capture or release that hold, and losing it means waiting out the full window.
  </Accordion>

  <Accordion title="Reconcile Against the API, Not Your Own Timer">
    Do not track expiry with your own clock, and never recompute the deadline from `heldAt` yourself. The natural-day rule depends on the Mexico City calendar day and on the 23:00 cutoff, so `heldAt` plus seven times twenty-four hours is wrong by up to a day and a half. Read `holdExpired`, `holdExpiresAt`, and `holdDaysRemaining` from the payment: they are computed by the same rule the automatic sweep uses, so they cannot drift apart from it.
  </Accordion>

  <Accordion title="Capture Before the Final Day">
    Both risks that can cost you the money (the 23:00 cutoff on the last day, and the issuer dropping the hold early) grow as you approach day 7. If your business process allows it, capture in the first few days. Never build a process that captures late in the evening of the last day: 22:59 is the last minute, and a retry after a network error at 22:58 may not get a second chance.
  </Accordion>

  <Accordion title="Place Holds Early in the Day">
    The placement day is day 1 whatever the clock says, so a hold placed at 23:59 starts life with `holdDaysRemaining: 6` while one placed that morning starts with `7`. When you control the timing, for example in a nightly batch, place holds early in the day rather than in the last minutes of it, and remember that a batch straddling local midnight produces holds whose windows end a full day apart.
  </Accordion>

  <Accordion title="Send an externalId on Every Charge and Hold">
    It costs one field and it is the difference between a safe retry and a duplicate hold on your customer's card. Use your own order reference, and remember that a declined attempt spends the key: a genuine second attempt at the same order needs a new one.
  </Accordion>
</AccordionGroup>

## Next Steps

<CardGroup cols={2}>
  <Card title="Charge a Saved Card" icon="money-bill" href="/payment-method/charge">
    Charge a customer's saved card immediately, with no hold step.
  </Card>

  <Card title="Process Refunds" icon="rotate-left" href="/payment-method/refund">
    Refund a captured hold, and see why a live hold must be released instead.
  </Card>

  <Card title="Manage Payment Methods" icon="list" href="/payment-method/manage">
    List payments, filter by hold status, and inspect the hold fields.
  </Card>

  <Card title="Payment Methods Overview" icon="vault" href="/payment-method/overview">
    Review payment method statuses, payment statuses, and use cases.
  </Card>
</CardGroup>
