Skip to content

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 ​

ActionEndpointPurpose
Get checkoutGET /api/merchant/v1/checkouts/{checkout_uuid}Read the current checkout state.
CapturePOST /api/merchant/v1/checkouts/{checkout_uuid}/captureFinalize an authorized checkout; charge the customer.
CancelPOST /api/merchant/v1/checkouts/{checkout_uuid}/cancelVoid an authorized checkout; release the customer's hold.

See the API reference for complete request and response schemas.


The checkout lifecycle ​

Handle the lifecycle in three phases:

PhaseWhat it meansStatus valuesWhat to do
Awaiting paymentCustomer hasn't finished paying.created, pendingWait.
AuthorizedFunds are held on the customer's card. Ready to capture.authorizedCapture (or cancel).
DoneTerminal (or settling into terminal).captured ✅, voided, failed, refunding, refundedIf captured, fulfill. Otherwise the order isn't paid (or has been refunded).

The full status table:

StatusMeaning
createdCheckout created; customer hasn't paid yet.
pendingCustomer is in the Maytes window.
authorizedPayment authorized (funds held). Ready to capture.
capturingCapture 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.
capturedFunds captured. Order is paid. ✅
refundingRefund submitted; awaiting settlement. Normal interim state after /refund.
refundedRefund confirmed. Terminal.
voidedAuthorization released (you canceled, or the hold expired).
failedPayment failed. Terminal.

Reading the checkout status ​

You don't need this to learn the outcome

In the webhook flow, 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.

Assumes a maytes client constructed at startup.

ts
const checkout = await maytes.getCheckout({ checkoutUuid });
console.log(checkout.data.status);
python
checkout = maytes.get_checkout(checkout_uuid)
print(checkout.data.status)
php
$checkout = $maytes->getCheckout($checkoutUuid);
echo $checkout->getData()->getStatus();
go
checkout, _, err := api.DefaultAPI.GetCheckout(ctx, checkoutUuid).Execute()
if err != nil { /* handle */ }
fmt.Println(checkout.Data.GetStatus())
java
GetCheckoutResponse checkout = maytes.getCheckout(checkoutUuid);
System.out.println(checkout.getData().getStatus());
ruby
checkout = MAYTES.get_checkout(checkout_uuid)
puts checkout.data.status
csharp
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.

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 and the assembled handler.

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

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

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:

ts
const captured = await maytes.captureCheckout({
  checkoutUuid,
  captureCheckoutRequest: { expectedTotalAmount: 4500, currency: 'AUD' },
});
// "captured" — the money has moved; fulfill now.
console.log(captured.data.status);
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
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
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
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
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
var captured = await maytes.CaptureCheckoutAsync(checkoutUuid,
    new CaptureCheckoutRequest
    {
        ExpectedTotalAmount = 4500,
        Currency = "AUD",
    });
// "captured" — the money has moved; fulfill now.
Console.WriteLine(captured.Data.Status);
FieldTypeNotes
expected_total_amountintegerAssertion. Mismatch → 422 amount_mismatch.
currencystringAssertion. Mismatch → 422 currency_mismatch.
merchant_order_idstringAssertion. Mismatch → 422 order_mismatch.
metadataobjectEchoed 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.

ts
// The request body is optional — pass an empty object for a plain cancel.
await maytes.cancelCheckout({ checkoutUuid, cancelCheckoutRequest: {} });
python
maytes.cancel_checkout(checkout_uuid)
php
$maytes->cancelCheckout($checkoutUuid);
go
_, _, err := api.DefaultAPI.CancelCheckout(ctx, checkoutUuid).Execute()
if err != nil { /* handle */ }
java
// The request body is optional — pass null for a plain cancel.
maytes.cancelCheckout(checkoutUuid, null);
ruby
MAYTES.cancel_checkout(checkout_uuid)
csharp
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.


Putting it together: your webhook handler ​

In the webhook flow, your checkout.authorized handler deduplicates the event, runs final checks, captures or cancels, and then fulfills:

event comes from verifyWebhookSignature, and maytes is a client constructed at startup. You provide alreadyProcessed, markProcessed, and orders.

ts
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
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
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
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
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
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
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.

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 ​

HTTPcodeOperationCause
409not_capturablecaptureThe checkout isn't in a capturable state (e.g. not yet authorized, or already voided).
409session_not_voidablecancelThe checkout can't be voided — typically already captured.
422amount_mismatchcaptureexpected_total_amount didn't match the authorized amount.
422currency_mismatchcapturecurrency didn't match.
422order_mismatchget / capture / cancelmerchant_order_id didn't match.
422no_active_sessioncaptureThe checkout has no active payment session to capture.
404not_foundallNo such checkout for your merchant account.

Full list per operation in the 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 — the checkout.authorized event that triggers capture in the recommended flow.
  • Create a checkout — the step that produces the checkout_uuid you capture here.
  • API reference — every field and error.