> ## Knowledge Base Index
> Fetch the complete knowledge base index at: https://corebycarlo.crisp.help/sitemap.xml
> Use this file to discover available pages before exploring further.
> Pure-Markdown content can be obtained by appending a '.md' suffix to the content URLs listed in the sitemap (without the trailing slash).

# Integrating Core payments into WooCommerce: developer guide

# Integrating Core payments into WooCommerce: developer guide

This guide is for the developer setting up Core by Carlo on a WooCommerce store. It assumes you are comfortable writing a WordPress plugin. Core is integrated as a WooCommerce payment gateway using the hosted payment page flow. The customer is redirected to a Core-hosted page, pays there, and returns to the store. Card details never touch the merchant's server, so the PCI scope stays minimal.

Before you start: 

* **request your sandbox access.** Sandbox accounts are not self-service. Contact support@corebycarlo.com to have your test environment created. You will receive credentials, an API key, and access to the sandbox Partner Dashboard. Production access is issued separately, once your integration is validated.
* API documentation: https://documentation.corebycarlo.com/home/getting-started

### 1. The flow

1. Customer places the order. WooCommerce calls your gateway's `process_payment()`.
2. Your plugin calls `POST /transactions` on the Core API and receives a `paymentPageUrl`.
3. WooCommerce redirects the customer to that URL.
4. The customer pays. Core redirects them to your success or failure URL.
5. Core sends the final status to your callback URL.
6. Your plugin verifies that status against the Core API, then marks the order as paid.
Step 6 is not optional. See section 6.

### 2. Prerequisites

* Sandbox access is created by Core on request, not by self-registration.
* Once your environment is set up, you will find your API key in the Partner Dashboard under **Developer → Configuration**, along with the three URLs to configure: success, failure, and callback.
**Base URLs:**
|  | Sandbox | Production |
| ---- |
| Auth | https://sandbox-api.corebycarlo.com/api/v1/auth/partner | https://api.corebycarlo.com/api/v1/auth/partner |
| API | https://sandbox-api.corebycarlo.com/api/v1/partner | https://api.corebycarlo.com/api/v1/partner |
All requests are JSON. All endpoints except login require a Bearer token.

### 3. Plugin structure

Register the gateway class after plugins load, and declare it to WooCommerce:
```
add_action( 'plugins_loaded', 'core_init_gateway' );

function core_init_gateway() {
    class WC_Gateway_Core extends WC_Payment_Gateway {
        public function __construct() {
            $this->id                 = 'core_by_carlo';
            $this->method_title       = 'Core by Carlo';
            $this->method_description = 'Accept card payments with Core by Carlo.';
            $this->has_fields         = false;
            $this->supports           = array( 'products', 'refunds' );

            $this->init_form_fields();
            $this->init_settings();

            $this->title = $this->get_option( 'title' );

            add_action(
                'woocommerce_update_options_payment_gateways_' . $this->id,
                array( $this, 'process_admin_options' )
            );
        }
    }
}

add_filter( 'woocommerce_payment_gateways', function ( $methods ) {
    $methods[] = 'WC_Gateway_Core';
    return $methods;
} );
```
`has_fields` stays false: there is no card form on your site.
Your settings screen should expose the merchant's credentials, the API key, and a sandbox toggle. Store credentials with the WooCommerce Settings API and never expose them client-side.
**Currency guard.** Core settles in euros and the transaction request has no currency field. Add an availability check so the method disappears on a non-euro store rather than charging the wrong amount:
```
public function is_available() {
    return parent::is_available() && get_woocommerce_currency() === 'EUR';
}
```

### 4. Authentication

Call the login endpoint with the merchant credentials and API key to obtain a Bearer token. Check the Login page of the API reference for the exact request body.
The token is valid for **90 days**. Do not request one per transaction. Cache it in a transient and refresh it when a call comes back `401`:
```
$token = get_transient( 'core_access_token' );
if ( ! $token ) {
    $token = $this->login();
    set_transient( 'core_access_token', $token, 80 * DAY_IN_SECONDS );
}
```
Refreshing on failure is more reliable than trusting an expiry calculation.

### 5. Creating the payment

```
public function process_payment( $order_id ) {
    $order = wc_get_order( $order_id );

    $response = wp_remote_post( $this->api_url . '/transactions', array(
        'headers' => array(
            'Authorization' => 'Bearer ' . $this->get_token(),
            'Content-Type'  => 'application/json',
        ),
        'body' => wp_json_encode( array(
            'amount'         => (float) $order->get_total(),
            'successUrl'     => $this->get_return_url( $order ),
            'failedUrl'      => $order->get_checkout_payment_url(),
            'orderReference' => (string) $order->get_id(),
            'description'    => 'Order #' . $order->get_order_number(),
            'metadata'       => array(
                'customerEmail' => $order->get_billing_email(),
            ),
            'paymentMethod'  => 'CREDIT_CARD',
        ) ),
        'timeout' => 30,
    ) );

    if ( is_wp_error( $response ) ) {
        wc_add_notice( 'Payment error: unable to reach the payment provider.', 'error' );
        return array( 'result' => 'failure' );
    }

    $body = json_decode( wp_remote_retrieve_body( $response ), true );

    if ( empty( $body['paymentPageUrl'] ) ) {
        wc_add_notice( 'Payment error: could not start the payment.', 'error' );
        return array( 'result' => 'failure' );
    }

    $order->update_meta_data( '_core_transaction_id', $body['id'] );
    $order->save();

    return array(
        'result'   => 'success',
        'redirect' => $body['paymentPageUrl'],
    );
}
```
Three things to note.
* **Store the transaction id on the order.** You need it to verify the callback and to refund later.
* **Do not call `payment_complete()` here.** The customer has not paid yet. Leave the order pending.
* **Payment sessions expire.** The response carries an `expiresAt`. If the customer abandons and comes back, create a new session. Never reuse a stored `paymentPageUrl`.
* **Redirect, not iframe.** 3D Secure requires a full-page redirect. Embedding the payment page in an iframe will break authentication.

### 6. Handling the callback, and why you must verify it

Core sends a `POST` to your callback URL when a transaction reaches a final state. Register a handler using the WooCommerce API endpoint:
```
add_action( 'woocommerce_api_wc_gateway_core', 'core_handle_callback' );
```
Your callback URL is then https://yourstore.com/?wc-api=wc_gateway_core.
**This URL is public and guessable. Treat the payload as untrusted input.**
The callback body is not signed. Anyone could send you a forged `COMPLETED` status and receive goods without paying. So never mark an order paid from the payload. Read only the transaction id from it, then ask the Core API what actually happened:
```
function core_handle_callback() {
    $payload = json_decode( file_get_contents( 'php://input' ), true );

    $transaction_id = $payload['transaction']['id'] ?? null;
    if ( ! $transaction_id ) {
        status_header( 400 );
        exit;
    }

    // Never trust the payload status. Ask the API.
    $transaction = core_fetch_transaction( $transaction_id );

    $order_id = $transaction['metadata']['orderReference'] ?? null;
    $order    = $order_id ? wc_get_order( $order_id ) : null;

    if ( ! $order ) {
        status_header( 404 );
        exit;
    }

    // Idempotency: the same callback can arrive twice.
    if ( $order->is_paid() ) {
        status_header( 200 );
        exit;
    }

    // Amount check: the order total must match what was actually paid.
    if ( (float) $transaction['amount'] !== (float) $order->get_total() ) {
        $order->update_status( 'on-hold', 'Core: amount mismatch, manual review required.' );
        status_header( 200 );
        exit;
    }

    if ( 'COMPLETED' === $transaction['status'] ) {
        $order->payment_complete( $transaction_id );
    } elseif ( in_array( $transaction['status'], array( 'FAILED', 'CANCELLED' ), true ) ) {
        $order->update_status( 'failed', 'Core: payment ' . strtolower( $transaction['status'] ) );
    }

    status_header( 200 );
    exit;
}
```
Return `200` quickly. If you have slow work to do, queue it.
**Do not confirm orders from the success URL.** A customer can close the browser before being redirected, and the redirect can be reached without a payment having succeeded. The verified callback is the only source of truth.
### 7. Refunds

Core cancels a transaction in full. There is no amount parameter, and the window is **3 days** from the payment date.
WooCommerce, on the other hand, lets a merchant refund a single line item. You must reject that case explicitly, otherwise a merchant who tries to refund 30 EUR of a 90 EUR order will have the whole order cancelled without realising it:
```
public function process_refund( $order_id, $amount = null, $reason = '' ) {
    $order = wc_get_order( $order_id );

    if ( (float) $amount !== (float) $order->get_total() ) {
        return new WP_Error(
            'core_partial_refund',
            'Core supports full refunds only. Refund the full order amount, or contact Core support for a partial refund.'
        );
    }

    // PUT /transactions/{id}/cancel with an optional comment
    // ...
}
```
Also handle the 30-day limit: past that window the API will refuse, and the merchant deserves a clear message rather than a silent failure.
### 8. Checkout Blocks compatibility

WooCommerce's default checkout is block-based. A gateway registered only through the PHP class will not appear there.
You need two extra pieces:
* A server-side class extending `Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType`, registered on the `woocommerce_blocks_payment_method_type_registration` hook.
* A client-side script calling `registerPaymentMethod` from the blocks registry, with a build step that externalises the WooCommerce packages.
The `name` on the JS side and the `$name` on the PHP side must match your gateway id.
Payment processing itself stays where it is. The Checkout block converts the submitted payment data and calls your existing `process_payment()`, so you do not duplicate any logic.
You should also declare the plugin compatible with WooCommerce's High-Performance Order Storage, otherwise merchants will see an incompatibility warning in their admin.
### 9. Testing

If you have not requested your sandbox access yet, do it now: support@corebycarlo.com. You cannot test without it.
Sandbox transactions do not move real money. Test cards are listed in the Test Cards page of the API reference.

**Test at minimum:**
* A successful payment, end to end
* A failed payment, and that the order goes to failed rather than staying pending forever
* A customer who closes the browser after paying but before the redirect. The order must still be marked paid by the callback.
* A duplicated callback. The order must not be processed twice.
* A forged callback: send yourself a `COMPLETED` payload for an unpaid order. **The order must not be marked as paid.** If it is, your verification step is not working.
* A full refund
* An attempted partial refund, which must fail with a readable message
One caveat on the sandbox: it simulates the acquirer, so failure scenarios are not always reproducible. Mock your own client where you need to test failure handling.

### 10. Go-live checklist

* ✅ Production credentials and API key configured
* ✅ Success, failure and callback URLs set in the production dashboard
* ✅ Callback URL is HTTPS
* ✅ Callback verification against the API confirmed working
* ✅ Idempotency check in place
* ✅ Amount comparison in place
* ✅ Store currency is EUR, and the currency guard is active
* ✅ Partial refunds rejected with a clear message
* ✅ Gateway visible and working on the block checkout
* ✅ HPOS compatibility declared
* ✅ Credentials stored server-side only, never logged
* ✅ One real low-value transaction tested in production, then refunded

Questions during integration: support@corebycarlo.com