---
url: /capture-and-status.md
---
# Capture & checkout status

Maytes uses an **authorize-then-capture** model. When the customer completes the Maytes window, their funds are *authorized* (held on the card), not yet taken. **Capture** to finalize the checkout and move the money, or **cancel** to release the authorization if you cannot fulfill.

***

## The merchant-side actions

| Action | Endpoint | Purpose |
|---|---|---|
| **Get checkout** | [`GET /api/merchant/v1/checkouts/{checkout_uuid}`](/api-reference#api-1/tag/checkout-merchant/GET/api/merchant/v1/checkouts/{checkoutUuid}) | Read the current checkout state. |
| **Capture** | [`POST /api/merchant/v1/checkouts/{checkout_uuid}/capture`](/api-reference#api-1/tag/checkout-merchant/POST/api/merchant/v1/checkouts/{checkoutUuid}/capture) | Finalize an authorized checkout; charge the customer. |
| **Cancel** | [`POST /api/merchant/v1/checkouts/{checkout_uuid}/cancel`](/api-reference#api-1/tag/checkout-merchant/POST/api/merchant/v1/checkouts/{checkoutUuid}/cancel) | Void an authorized checkout; release the customer's hold. |

See the **[API reference](/api-reference)** for complete request and response schemas.

***

## The checkout lifecycle

```mermaid
flowchart LR
  created --> pending --> authorized
  authorized -->|capture| capturing --> captured
  authorized -->|cancel / expiry| voided
  pending -->|payment fails| failed
  captured -->|refund| refunding --> refunded
```

Handle the lifecycle in three phases:

| Phase | What it means | Status values | What to do |
|---|---|---|---|
| **Awaiting payment** | Customer hasn't finished paying. | `created`, `pending` | Wait. |
| **Authorized** | Funds are held on the customer's card. **Ready to capture.** | `authorized` | Capture (or cancel). |
| **Done** | Terminal (or settling into terminal). | `captured` ✅, `voided`, `failed`, `refunding`, `refunded` | If `captured`, fulfill. Otherwise the order isn't paid (or has been [refunded](/refunds)). |

The full status table:

| Status | Meaning |
|---|---|
| `created` | Checkout created; customer hasn't paid yet. |
| `pending` | Customer is in the Maytes window. |
| `authorized` | Payment authorized (funds held). Ready to capture. |
| `capturing` | Capture in flight. You won't see this as the response to your own `/capture` call — that call waits for the payment provider to confirm — but a concurrent `GET` can observe it. |
| `captured` | Funds captured. Order is paid. ✅ |
| `refunding` | Refund submitted; awaiting settlement. Normal interim state after [`/refund`](/refunds). |
| `refunded` | Refund confirmed. Terminal. |
| `voided` | Authorization released (you canceled, or the hold expired). |
| `failed` | Payment failed. Terminal. |

***

## Reading the checkout status

::: tip You don't need this to learn the outcome
In the [webhook flow](/webhooks), do not poll. The `checkout.authorized` event reports authorization, and the capture call returns the captured checkout synchronously.

Read the checkout for:

* **Reconciliation** — recover the current state when a delivery was missed or an order is in an unexpected state.
* **Support tooling** — answer whether an order was paid.
* **Refund state** — `refunded_amount` and `last_refund_at` change after you [issue a refund](/refunds).
  :::

Assumes a `maytes` client [constructed at startup](/build-your-integration#construct-the-client).

::: code-group

```ts [TypeScript]
const checkout = await maytes.getCheckout({ checkoutUuid });
console.log(checkout.data.status);
```

```python [Python]
checkout = maytes.get_checkout(checkout_uuid)
print(checkout.data.status)
```

```php [PHP]
$checkout = $maytes->getCheckout($checkoutUuid);
echo $checkout->getData()->getStatus();
```

```go [Go]
checkout, _, err := api.DefaultAPI.GetCheckout(ctx, checkoutUuid).Execute()
if err != nil { /* handle */ }
fmt.Println(checkout.Data.GetStatus())
```

```java [Java]
GetCheckoutResponse checkout = maytes.getCheckout(checkoutUuid);
System.out.println(checkout.getData().getStatus());
```

```ruby [Ruby]
checkout = MAYTES.get_checkout(checkout_uuid)
puts checkout.data.status
```

```csharp [.NET]
var checkout = await maytes.GetCheckoutAsync(checkoutUuid);
Console.WriteLine(checkout.Data.Status);
```

:::

The response:

```json
{
  "data": {
    "checkout_uuid": "019fbb9e-ecd1-7059-99f3-f8bf47b63931",
    "merchant_order_id": "order_1001",
    "status": "authorized",
    "amount": 4500,
    "currency": "AUD",
    "captured_at": null,
    "merchant_expires_at": null,
    "transaction_ref": null,
    "checkout_url": "https://checkout.maytes.co/?id=019fbb9e-ecd1-7059-99f3-f8bf47b63931",
    "return_url": "https://shop.example.com/thanks?order=order_1001",
    "cancel_url": "https://shop.example.com/cart",
    "service_fee": {
      "gross": 0,
      "discount": 0,
      "net": 0,
      "breakdown": []
    },
    "refunded_amount": 0,
    "merchant_processor_fee": 0,
    "merchant_service_fee": 0,
    "last_refund_at": null
  }
}
```

Branch on `status`. On capture, `captured_at` changes from `null` to an ISO timestamp. `transaction_ref` is Maytes' transaction reference; it's filled in shortly after capture, so it can still be `null` right after. `service_fee` is the platform fee shown to the customer, distinct from your cart-level `fees`. The `refunded_amount` and `last_refund_at` fields track [refunds](/refunds).

Optionally pass `merchant_order_id` as a query parameter when looking up a checkout from a stored UUID mapping. If it differs from the checkout's stored value, the request returns `422 order_mismatch`.

***

## Implement capture

### When to call capture

Capture immediately from your **`checkout.authorized` webhook handler**. The customer remains on a processing screen until you respond, and the authorization is auto-voided after **2 minutes**. Capture success is your fulfillment trigger. See [timing expectations](/webhooks#timing-expectations) and [the assembled handler](#putting-it-together-your-webhook-handler).

Capture is **server-side only** because it uses your OAuth Bearer token.

::: warning The capture window is 2 minutes
Capture immediately after authorization. An uncaptured authorization is **automatically voided after 2 minutes**, releasing the customer's funds. If subscribed, you receive a [`checkout.voided` webhook](/webhooks#event-types) with `reason: "auth_expired"`; a late capture returns `409 not_capturable`. Use the window for retries, not to delay capture. You can set an order-level deadline with `merchant_expires_at` on [create](/create-checkout).
:::

### Capture the full amount

Call `/capture` with an empty body to capture the full authorized amount. Include `expected_total_amount` and `currency` so Maytes can verify the checkout against your order:

::: code-group

```ts [TypeScript]
const captured = await maytes.captureCheckout({
  checkoutUuid,
  captureCheckoutRequest: { expectedTotalAmount: 4500, currency: 'AUD' },
});
// "captured" — the money has moved; fulfill now.
console.log(captured.data.status);
```

```python [Python]
from maytes_api_client.models.capture_checkout_request import CaptureCheckoutRequest

captured = maytes.capture_checkout(checkout_uuid, CaptureCheckoutRequest(
    expected_total_amount=4500,
    currency="AUD",
))
# "captured" — the money has moved; fulfill now.
print(captured.data.status)
```

```php [PHP]
use MaytesApiClient\Model\CaptureCheckoutRequest;

$captured = $maytes->captureCheckout($checkoutUuid, new CaptureCheckoutRequest([
    'expected_total_amount' => 4500,
    'currency'              => 'AUD',
]));
// "captured" — the money has moved; fulfill now.
echo $captured->getData()->getStatus();
```

```go [Go]
captureReq := *maytes.NewCaptureCheckoutRequest()
captureReq.SetExpectedTotalAmount(4500)
captureReq.SetCurrency("AUD")

captured, _, err := api.DefaultAPI.CaptureCheckout(ctx, checkoutUuid).
    CaptureCheckoutRequest(captureReq).Execute()
if err != nil { /* handle */ }
// "captured" — the money has moved; fulfill now.
fmt.Println(captured.Data.GetStatus())
```

```java [Java]
CaptureCheckoutResponse captured = maytes.captureCheckout(checkoutUuid,
    new CaptureCheckoutRequest()
        .expectedTotalAmount(4500)
        .currency("AUD"));
// "captured" — the money has moved; fulfill now.
System.out.println(captured.getData().getStatus());
```

```ruby [Ruby]
captured = MAYTES.capture_checkout(checkout_uuid,
  MaytesApiClient::CaptureCheckoutRequest.new(
    expected_total_amount: 4500,
    currency: 'AUD',
  ),
)
# "captured" — the money has moved; fulfill now.
puts captured.data.status
```

```csharp [.NET]
var captured = await maytes.CaptureCheckoutAsync(checkoutUuid,
    new CaptureCheckoutRequest
    {
        ExpectedTotalAmount = 4500,
        Currency = "AUD",
    });
// "captured" — the money has moved; fulfill now.
Console.WriteLine(captured.Data.Status);
```

:::

| Field | Type | Notes |
|---|---|---|
| `expected_total_amount` | integer | Assertion. Mismatch → `422 amount_mismatch`. |
| `currency` | string | Assertion. Mismatch → `422 currency_mismatch`. |
| `merchant_order_id` | string | Assertion. Mismatch → `422 order_mismatch`. |
| `metadata` | object | Echoed back. |

An empty body (`{}`) captures the full authorized amount with no assertions.

**Capture is synchronous.** The call waits for payment-provider confirmation, then returns `status: "captured"` and `captured_at`:

```json
{
  "data": {
    "checkout_uuid": "019fbb9e-ecd1-7059-99f3-f8bf47b63931",
    "merchant_order_id": "order_1001",
    "status": "captured",
    "captured_at": "2026-09-14T03:31:12.000Z",
    "amount": 4500,
    "currency": "AUD"
  }
}
```

A 200 response means the checkout is captured and ready for fulfillment.

***

## If you can't fulfill the order

If you cannot fulfill, call **cancel** before capture. This releases the customer's authorization, moves the checkout to `voided`, and moves no funds. In the webhook flow, cancel promptly so the customer can leave the processing screen.

::: code-group

```ts [TypeScript]
// The request body is optional — pass an empty object for a plain cancel.
await maytes.cancelCheckout({ checkoutUuid, cancelCheckoutRequest: {} });
```

```python [Python]
maytes.cancel_checkout(checkout_uuid)
```

```php [PHP]
$maytes->cancelCheckout($checkoutUuid);
```

```go [Go]
_, _, err := api.DefaultAPI.CancelCheckout(ctx, checkoutUuid).Execute()
if err != nil { /* handle */ }
```

```java [Java]
// The request body is optional — pass null for a plain cancel.
maytes.cancelCheckout(checkoutUuid, null);
```

```ruby [Ruby]
MAYTES.cancel_checkout(checkout_uuid)
```

```csharp [.NET]
await maytes.CancelCheckoutAsync(checkoutUuid);
```

:::

You can cancel only while the checkout is awaiting payment or **authorized**. After capture, the API returns **`409 session_not_voidable`**. Return captured funds through a [refund](/refunds).

***

## Putting it together: your webhook handler

In the [webhook flow](/webhooks), your `checkout.authorized` handler deduplicates the event, runs final checks, captures or cancels, and then fulfills:

`event` comes from [`verifyWebhookSignature`](/webhooks#verify-the-signature), and `maytes` is a client [constructed at startup](/build-your-integration#construct-the-client). You provide `alreadyProcessed`, `markProcessed`, and `orders`.

::: code-group

```ts [TypeScript]
import { CaptureCheckoutCommand, CancelCheckoutCommand, MaytesWebhookEvent } from '@maytes/api-client-js';

async function handleCheckoutAuthorized(event: MaytesWebhookEvent) {
  // `data` values arrive as unknown — coerce at this boundary, once.
  const checkoutUuid = String(event.data.checkout_uuid);
  const merchantOrderId = String(event.data.merchant_order_id);
  const amount = Number(event.data.amount);
  const currency = String(event.data.currency);

  // 1. Dedupe — deliveries are at-least-once.
  if (await alreadyProcessed(event.id)) return;

  // 2. Your final checks (stock, fraud, order still open).
  const order = await orders.findByRef(merchantOrderId);
  if (!order || !order.isFulfillable()) {
    // Can't fulfill → release the hold so the customer isn't left waiting.
    await maytes.cancelCheckout({ checkoutUuid, cancelCheckoutRequest: {} });
    return;
  }

  // 3. Capture — synchronous; 200 means the money moved.
  //    The assertions guard against acting on the wrong order.
  await maytes.captureCheckout({
    checkoutUuid,
    captureCheckoutRequest: {
      expectedTotalAmount: amount,
      currency,
      merchantOrderId,
    },
  });

  // 4. Fulfill. This is your trigger — not the return_url landing.
  await orders.markPaidAndFulfill(order, checkoutUuid);
  await markProcessed(event.id);
}
```

```python [Python]
from maytes_api_client.models.capture_checkout_request import CaptureCheckoutRequest

def handle_checkout_authorized(event):
    checkout_uuid = event.data["checkout_uuid"]
    merchant_order_id = event.data["merchant_order_id"]

    # 1. Dedupe — deliveries are at-least-once.
    if already_processed(event.id):
        return

    # 2. Your final checks (stock, fraud, order still open).
    order = orders.find_by_ref(merchant_order_id)
    if order is None or not order.is_fulfillable():
        # Can't fulfill → release the hold so the customer isn't left waiting.
        maytes.cancel_checkout(checkout_uuid)
        return

    # 3. Capture — synchronous; 200 means the money moved.
    #    The assertions guard against acting on the wrong order.
    maytes.capture_checkout(checkout_uuid, CaptureCheckoutRequest(
        expected_total_amount=event.data["amount"],
        currency=event.data["currency"],
        merchant_order_id=merchant_order_id,
    ))

    # 4. Fulfill. This is your trigger — not the return_url landing.
    orders.mark_paid_and_fulfill(order, checkout_uuid)
    mark_processed(event.id)
```

```php [PHP]
use MaytesApiClient\Model\CaptureCheckoutRequest;

function handleCheckoutAuthorized(\MaytesApiClient\MaytesWebhookEvent $event): void
{
    global $maytes, $orders;

    $checkoutUuid = $event->data['checkout_uuid'];
    $merchantOrderId = $event->data['merchant_order_id'];

    // 1. Dedupe — deliveries are at-least-once.
    if (alreadyProcessed($event->id)) {
        return;
    }

    // 2. Your final checks (stock, fraud, order still open).
    $order = $orders->findByRef($merchantOrderId);
    if ($order === null || !$order->isFulfillable()) {
        // Can't fulfill → release the hold so the customer isn't left waiting.
        $maytes->cancelCheckout($checkoutUuid);
        return;
    }

    // 3. Capture — synchronous; 200 means the money moved.
    //    The assertions guard against acting on the wrong order.
    $maytes->captureCheckout($checkoutUuid, new CaptureCheckoutRequest([
        'expected_total_amount' => $event->data['amount'],
        'currency'              => $event->data['currency'],
        'merchant_order_id'     => $merchantOrderId,
    ]));

    // 4. Fulfill. This is your trigger — not the return_url landing.
    $orders->markPaidAndFulfill($order, $checkoutUuid);
    markProcessed($event->id);
}
```

```go [Go]
func handleCheckoutAuthorized(ctx context.Context, event *maytes.MaytesWebhookEvent) error {
    checkoutUuid, _ := event.Data["checkout_uuid"].(string)
    merchantOrderID, _ := event.Data["merchant_order_id"].(string)
    // JSON numbers decode to float64 in a map[string]interface{}.
    amount, _ := event.Data["amount"].(float64)
    currency, _ := event.Data["currency"].(string)

    // 1. Dedupe — deliveries are at-least-once.
    if alreadyProcessed(event.ID) {
        return nil
    }

    // 2. Your final checks (stock, fraud, order still open).
    order, err := orders.FindByRef(merchantOrderID)
    if err != nil || !order.IsFulfillable() {
        // Can't fulfill → release the hold so the customer isn't left waiting.
        _, _, cancelErr := api.DefaultAPI.CancelCheckout(ctx, checkoutUuid).Execute()
        return cancelErr
    }

    // 3. Capture — synchronous; 200 means the money moved.
    //    The assertions guard against acting on the wrong order.
    captureReq := *maytes.NewCaptureCheckoutRequest()
    captureReq.SetExpectedTotalAmount(int32(amount))
    captureReq.SetCurrency(currency)
    captureReq.SetMerchantOrderId(merchantOrderID)
    if _, _, err := api.DefaultAPI.CaptureCheckout(ctx, checkoutUuid).
        CaptureCheckoutRequest(captureReq).Execute(); err != nil {
        return err
    }

    // 4. Fulfill. This is your trigger — not the return_url landing.
    if err := orders.MarkPaidAndFulfill(order, checkoutUuid); err != nil {
        return err
    }
    return markProcessed(event.ID)
}
```

```java [Java]
import co.maytes.api.model.CaptureCheckoutRequest;
import co.maytes.api.webhook.WebhookVerifier.MaytesWebhookEvent;

void handleCheckoutAuthorized(MaytesWebhookEvent event) throws Exception {
    String checkoutUuid = (String) event.getData().get("checkout_uuid");
    String merchantOrderId = (String) event.getData().get("merchant_order_id");
    // JSON numbers arrive boxed — go via Number, not a direct Integer cast.
    int amount = ((Number) event.getData().get("amount")).intValue();
    String currency = (String) event.getData().get("currency");

    // 1. Dedupe — deliveries are at-least-once.
    if (alreadyProcessed(event.getId())) return;

    // 2. Your final checks (stock, fraud, order still open).
    Order order = orders.findByRef(merchantOrderId);
    if (order == null || !order.isFulfillable()) {
        // Can't fulfill → release the hold so the customer isn't left waiting.
        maytes.cancelCheckout(checkoutUuid, null);
        return;
    }

    // 3. Capture — synchronous; 200 means the money moved.
    //    The assertions guard against acting on the wrong order.
    maytes.captureCheckout(checkoutUuid, new CaptureCheckoutRequest()
        .expectedTotalAmount(amount)
        .currency(currency)
        .merchantOrderId(merchantOrderId));

    // 4. Fulfill. This is your trigger — not the return_url landing.
    orders.markPaidAndFulfill(order, checkoutUuid);
    markProcessed(event.getId());
}
```

```ruby [Ruby]
def handle_checkout_authorized(event)
  checkout_uuid = event.data['checkout_uuid']
  merchant_order_id = event.data['merchant_order_id']

  # 1. Dedupe — deliveries are at-least-once.
  return if already_processed?(event.id)

  # 2. Your final checks (stock, fraud, order still open).
  order = orders.find_by_ref(merchant_order_id)
  unless order&.fulfillable?
    # Can't fulfill → release the hold so the customer isn't left waiting.
    MAYTES.cancel_checkout(checkout_uuid)
    return
  end

  # 3. Capture — synchronous; 200 means the money moved.
  #    The assertions guard against acting on the wrong order.
  MAYTES.capture_checkout(checkout_uuid,
    MaytesApiClient::CaptureCheckoutRequest.new(
      expected_total_amount: event.data['amount'],
      currency: event.data['currency'],
      merchant_order_id: merchant_order_id,
    ),
  )

  # 4. Fulfill. This is your trigger — not the return_url landing.
  orders.mark_paid_and_fulfill(order, checkout_uuid)
  mark_processed(event.id)
end
```

```csharp [.NET]
using MaytesApiClient.Model;
using MaytesApiClient.Webhooks;

async Task HandleCheckoutAuthorizedAsync(MaytesWebhookEvent evt)
{
    // GetString() is nullable; these envelope fields are always present.
    var checkoutUuid = evt.Data["checkout_uuid"].GetString()!;
    var merchantOrderId = evt.Data["merchant_order_id"].GetString()!;

    // 1. Dedupe — deliveries are at-least-once.
    if (await AlreadyProcessedAsync(evt.Id)) return;

    // 2. Your final checks (stock, fraud, order still open).
    var order = await orders.FindByRefAsync(merchantOrderId);
    if (order is null || !order.IsFulfillable())
    {
        // Can't fulfill → release the hold so the customer isn't left waiting.
        await maytes.CancelCheckoutAsync(checkoutUuid);
        return;
    }

    // 3. Capture — synchronous; 200 means the money moved.
    //    The assertions guard against acting on the wrong order.
    await maytes.CaptureCheckoutAsync(checkoutUuid, new CaptureCheckoutRequest
    {
        ExpectedTotalAmount = evt.Data["amount"].GetInt32(),
        Currency = evt.Data["currency"].GetString()!,
        MerchantOrderId = merchantOrderId,
    });

    // 4. Fulfill. This is your trigger — not the return_url landing.
    await orders.MarkPaidAndFulfillAsync(order, checkoutUuid);
    await MarkProcessedAsync(evt.Id);
}
```

:::

Handle these edge cases:

* **Capture returns `409 not_capturable`**: treat the order as unpaid. The checkout was already voided, usually because authorization expired before the retry. For a transient failure, retry once, then cancel so the customer does not remain on the processing screen.
* **A duplicate delivery**: capture is idempotent (below), so a re-run won't double-charge. Deduping on `event.id` is what stops *your* fulfillment side effects running twice.
* **Keep it inside the window**: deliveries time out after **15 seconds**, and authorization is voided after **2 minutes**. Verify and capture synchronously; queue emails and warehouse calls until after you respond. See [timing expectations](/webhooks#timing-expectations).

***

## Idempotency & retries

Both **capture** and **cancel** are **idempotent on terminal states**. Capturing an already-captured checkout or canceling an already-voided checkout returns success without repeating the operation. You can retry after network failures.

If capture is attempted twice, for example from a retried webhook delivery, the second call is a no-op success. Use the `merchant_order_id` set at create as your end-to-end deduplication key, and deduplicate webhook deliveries by envelope `id`.

***

## Common errors

| HTTP | `code` | Operation | Cause |
|---|---|---|---|
| `409` | `not_capturable` | capture | The checkout isn't in a capturable state (e.g. not yet `authorized`, or already `voided`). |
| `409` | `session_not_voidable` | cancel | The checkout can't be voided — typically already captured. |
| `422` | `amount_mismatch` | capture | `expected_total_amount` didn't match the authorized amount. |
| `422` | `currency_mismatch` | capture | `currency` didn't match. |
| `422` | `order_mismatch` | get / capture / cancel | `merchant_order_id` didn't match. |
| `422` | `no_active_session` | capture | The checkout has no active payment session to capture. |
| `404` | `not_found` | all | No such checkout for your merchant account. |

Full list per operation in the [API reference](/api-reference).

***

## Considerations

* **Keep credentials server-side.** Capture and cancel use your OAuth Bearer token; never expose `client_id` or `client_secret` to the browser.
* **Do not partially capture.** Partial capture is not supported. To fulfill only part of an order, cancel and create a new checkout for the smaller amount.

***

## What's next

* [Webhooks](/webhooks) — the `checkout.authorized` event that triggers capture in the recommended flow.
* [Create a checkout](/create-checkout) — the step that produces the `checkout_uuid` you capture here.
* [API reference](/api-reference) — every field and error.
