# WooCommerce > Forward WooCommerce orders when they are paid — a drop-in PHP plugin on woocommerce_order_status_processing, or a REST webhook to your own service — and push tracking back. Source: https://swaglists.com/docs/woocommerce Two ways to connect WooCommerce: | | A. PHP plugin (recommended) | B. WooCommerce webhook | | --- | --- | --- | | Runs | Inside WordPress | On your own server | | Trigger | `woocommerce_order_status_processing` hook | `order.updated` webhook, filtered to `processing` | | Tracking back | Same plugin (REST route) | Your server calls the WooCommerce REST API | Paid WooCommerce orders move to **Processing** — that is when we forward them. `pending` (unpaid) and `on-hold` (awaiting bank transfer) orders are ignored until they are paid. **Before you start:** your WooCommerce products need our SKUs (export a **WooCommerce CSV** from **Products → Export** in our dashboard and import it, or set your variation SKUs to your SKUs here). Create a live key and a webhook endpoint subscribed to `shipment.created` and `order.shipped` pointing at `https://your-store.example.com/wp-json/print/v1/webhook`. ## A. PHP plugin Save as `wp-content/mu-plugins/print-fulfillment.php` and add the constants to `wp-config.php`: ```php title="wp-config.php" define('PRINT_API_KEY', 'sk_live_…'); // sk_test_… on staging define('PRINT_WEBHOOK_SECRET', 'whsec_…'); // secret of your webhook endpoint ``` ```php title="print-fulfillment.php" get_meta('_print_order_id')) return; // already forwarded $items = []; foreach ($order->get_items() as $item_id => $item) { $product = $item->get_product(); $sku = $product ? $product->get_sku() : ''; if ($sku === '' || ($product && $product->is_virtual())) continue; // not a print product $items[] = ['sku' => $sku, 'quantity' => (int) $item->get_quantity(), 'external_line_id' => (string) $item_id]; } if (!$items) return; $has_shipping = $order->get_shipping_address_1() !== ''; $get = fn($field) => $has_shipping ? $order->{"get_shipping_$field"}() : $order->{"get_billing_$field"}(); $body = [ 'external_id' => 'woo:' . wp_parse_url(home_url(), PHP_URL_HOST) . ':' . $order_id, 'recipient' => [ 'name' => trim($get('first_name') . ' ' . $get('last_name')), 'company' => $get('company') ?: null, 'address1' => $get('address_1'), 'address2' => $get('address_2') ?: null, 'city' => $get('city'), 'state_code' => $get('state') ?: null, 'postal_code' => $get('postcode') ?: null, 'country_code' => $get('country'), 'phone' => ($has_shipping ? $order->get_shipping_phone() : '') ?: $order->get_billing_phone() ?: null, 'email' => $order->get_billing_email() ?: null, ], 'shipping_method' => 'standard', // map $order->get_shipping_methods() to economy | standard | express | priority 'items' => $items, 'packing_slip' => ['customer_order_number' => '#' . $order->get_order_number()], 'metadata' => ['source' => 'woocommerce'], ]; $json_body = wp_json_encode($body); $response = wp_remote_post(PRINT_API . '/orders', [ 'timeout' => 20, 'headers' => [ 'Authorization' => 'Bearer ' . PRINT_API_KEY, 'Content-Type' => 'application/json', // one key per distinct request: a retry replays the first response, a corrected order is a new request 'Idempotency-Key' => 'woo-' . hash('sha256', $json_body), ], 'body' => $json_body, ]); $status = is_wp_error($response) ? 0 : wp_remote_retrieve_response_code($response); $json = is_wp_error($response) ? null : json_decode(wp_remote_retrieve_body($response), true); if ($status === 201 || ($json['error']['code'] ?? '') === 'duplicate_external_id') { $print_id = $json['id'] ?? $json['error']['existing_id']; $order->update_meta_data('_print_order_id', $print_id); $order->add_order_note('Sent to production: ' . $print_id); $order->save(); return; } if ($status === 0 || $status === 429 || $status >= 500 || ($json['error']['code'] ?? '') === 'idempotency_in_progress') { // temporary problem: retry in 5 minutes as_schedule_single_action(time() + 300, 'print_forward_order', [$order_id], 'print'); return; } $order->add_order_note(sprintf('Could not send to production (%s): %s', $json['error']['code'] ?? $status, $json['error']['message'] ?? '')); } add_action('woocommerce_order_status_processing', 'print_forward_order'); add_action('print_forward_order', 'print_forward_order'); /** Cancel in production when the store order is canceled (only possible before production starts). */ add_action('woocommerce_order_status_cancelled', function ($order_id) { $order = wc_get_order($order_id); $print_id = $order ? $order->get_meta('_print_order_id') : ''; if (!$print_id) return; $response = wp_remote_post(PRINT_API . "/orders/$print_id/cancel", [ 'timeout' => 20, 'headers' => ['Authorization' => 'Bearer ' . PRINT_API_KEY, 'Content-Type' => 'application/json'], 'body' => wp_json_encode(['reason' => 'Canceled in WooCommerce']), ]); $code = wp_remote_retrieve_response_code($response); $order->add_order_note($code === 200 ? 'Production order canceled.' : 'Production order could not be canceled (already in production).'); }); /** Our webhooks: add tracking and complete the order when a parcel ships. */ add_action('rest_api_init', function () { register_rest_route('print/v1', '/webhook', [ 'methods' => 'POST', 'permission_callback' => '__return_true', // authenticated by the signature below 'callback' => function (WP_REST_Request $request) { $raw = $request->get_body(); if (!print_verify_webhook($raw, $request)) return new WP_REST_Response('invalid signature', 400); $event = json_decode($raw, true); $object = $event['data']['object']; $print_order_id = $event['type'] === 'shipment.created' ? $object['order_id'] : $object['id']; $orders = wc_get_orders(['meta_key' => '_print_order_id', 'meta_value' => $print_order_id, 'limit' => 1]); if (!$orders) return new WP_REST_Response(null, 204); $order = $orders[0]; if ($event['type'] === 'shipment.created') { // one note per parcel; true = also email it to the customer $order->add_order_note(sprintf('Shipped with %s: %s %s', strtoupper((string) $object['carrier']), $object['tracking_number'], $object['tracking_url']), true); } elseif ($event['type'] === 'order.shipped') { $order->update_status('completed'); // every parcel has shipped } return new WP_REST_Response(null, 204); }, ]); }); function print_verify_webhook(string $raw, WP_REST_Request $request, int $tolerance = 300): bool { $id = (string) $request->get_header('webhook-id'); $ts = (string) $request->get_header('webhook-timestamp'); $signatures = (string) $request->get_header('webhook-signature'); if ($id === '' || !ctype_digit($ts) || $signatures === '' || abs(time() - (int) $ts) > $tolerance) return false; $key = base64_decode(substr(PRINT_WEBHOOK_SECRET, strlen('whsec_')), true); $expected = base64_encode(hash_hmac('sha256', "$id.$ts.$raw", $key, true)); foreach (explode(' ', $signatures) as $entry) { [$version, $sig] = array_pad(explode(',', $entry, 2), 2, ''); if ($version === 'v1' && hash_equals($expected, $sig)) return true; } return false; } ``` Notes: - `as_schedule_single_action` is Action Scheduler, which ships with WooCommerce. - `external_id` is derived from the order id, so a hook that fires twice never creates two orders. The idempotency key is a hash of the request body: an Action Scheduler retry of the same request replays the first response, while an order you corrected after a failed forward (for example a fixed SKU, then set to Processing again) is sent as a new request instead of failing with `409 idempotency_key_reused`. - States: WooCommerce stores ISO state codes for countries that have them (US, CA, AU, …), which is what `state_code` expects. - If customers choose a faster shipping method, map `$order->get_shipping_methods()` (method ids/titles) to `express` or `priority`. ## B. WooCommerce webhook to your server Prefer to keep WordPress untouched? Create a webhook in **WooCommerce → Settings → Advanced → Webhooks**: topic **Order updated**, delivery URL `https://glue.example.com/webhooks/woo`, a long random **secret** (avoid `&`, `<`, `>` and quotes). Then forward paid orders from your server: ```php title="webhooks/woo.php" "woo:$host:{$o['id']}", 'recipient' => [ 'name' => trim($ship['first_name'] . ' ' . $ship['last_name']), 'company' => $ship['company'] ?: null, 'address1' => $ship['address_1'], 'address2' => $ship['address_2'] ?: null, 'city' => $ship['city'], 'state_code' => $ship['state'] ?: null, 'postal_code' => $ship['postcode'] ?: null, 'country_code' => $ship['country'], 'phone' => ($o['shipping']['phone'] ?? '') ?: ($o['billing']['phone'] ?: null), 'email' => $o['billing']['email'] ?: null, ], 'items' => array_values(array_map( fn($li) => ['sku' => $li['sku'], 'quantity' => $li['quantity'], 'external_line_id' => (string) $li['id']], array_filter($o['line_items'], fn($li) => !empty($li['sku'])) )), 'shipping_method' => 'standard', 'packing_slip' => ['customer_order_number' => '#' . $o['number']], ]; $body = json_encode($payload); $ch = curl_init('https://swaglists.com/api/v1/orders'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 20, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('PRINT_API_KEY'), 'Content-Type: application/json', // one key per distinct payload (it contains external_id) — never one fixed key per order 'Idempotency-Key: woo-' . hash('sha256', $body), ], CURLOPT_POSTFIELDS => $body, ]); $response = json_decode(curl_exec($ch), true); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); $code = $response['error']['code'] ?? ''; // 201 created, or 409 duplicate_external_id: already forwarded (a later update of the order) — both are fine. if ($status === 201 || $code === 'duplicate_external_id') { http_response_code(200); exit; } error_log(sprintf('Order %s not forwarded: HTTP %d %s %s', $payload['external_id'], $status, $code, $response['error']['message'] ?? '')); http_response_code(500); ``` `order.updated` fires many times per order, and the key is a hash of the payload, so every repeat is harmless: - The same payload again replays the first response (`Idempotent-Replayed: true`). - A later update of an order that was already forwarded (for example a new phone number) has a new key and is answered with `409 duplicate_external_id` thanks to `external_id` — treated as done. - An order you corrected after a failed forward (for example a fixed SKU) is sent as a new request and created. Don't derive the key from the order id alone here: the next edit of the order would reuse it with a different body and get `409 idempotency_key_reused`, and a corrected order would be refused for 24 hours. WooCommerce does not retry failed deliveries (the next update of the order is the next attempt) and disables a webhook after more than 5 consecutive failures — check its status if orders stop arriving. For automatic retries, use the plugin (A). To push tracking back from your server, handle our `shipment.created` webhook ([verify it](https://swaglists.com/docs/webhooks.md#verify-signatures)), find the WooCommerce order id in the order's `external_id`, then: ```bash title="Add a customer note and complete the order" curl -X POST https://your-store.example.com/wp-json/wc/v3/orders/1042/notes -u ck_xxx:cs_xxx \ -H "Content-Type: application/json" \ -d '{ "note": "Shipped with USPS: 9400111899223197428490 https://tools.usps.com/go/TrackConfirmAction?tLabels=9400111899223197428490", "customer_note": true }' curl -X PUT https://your-store.example.com/wp-json/wc/v3/orders/1042 -u ck_xxx:cs_xxx \ -H "Content-Type: application/json" -d '{ "status": "completed" }' ``` ## Field mapping | Our field | WooCommerce order | | --- | --- | | `external_id` | `woo::` | | `recipient.*` | `shipping.*` (fall back to `billing.*` when the order has no shipping address) | | `recipient.state_code` / `postal_code` / `country_code` | `state` / `postcode` / `country` | | `recipient.phone` | `shipping.phone`, else `billing.phone` | | `recipient.email` | `billing.email` | | `items[].sku`, `quantity`, `external_line_id` | `line_items[].sku`, `quantity`, `id` | | `packing_slip.customer_order_number` | `#` + `number` |