Skip to content

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.

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:

wp-config.php
define('PRINT_API_KEY', 'sk_live_…');          // sk_test_… on staging
define('PRINT_WEBHOOK_SECRET', 'whsec_…');      // secret of your webhook endpoint
print-fulfillment.php
<?php
/**
 * Plugin Name: Print fulfillment
 * Description: Forwards paid orders to Swaglists and adds tracking when they ship.
 */
if (!defined('ABSPATH')) exit;

const PRINT_API = 'https://swaglists.com/api/v1';

/** Forward an order when it is paid (status → processing). Also retried by Action Scheduler. */
function print_forward_order($order_id) {
    $order = wc_get_order($order_id);
    if (!$order || $order->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:

webhooks/woo.php
<?php
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_WC_WEBHOOK_SIGNATURE'] ?? '';

// WooCommerce pings new webhooks with an unsigned "webhook_id=…" body: acknowledge it.
if ($sig === '' && str_starts_with($raw, 'webhook_id=')) { http_response_code(200); exit; }
if (!hash_equals(base64_encode(hash_hmac('sha256', $raw, getenv('WOO_WEBHOOK_SECRET'), true)), $sig)) { http_response_code(401); exit; }

$o = json_decode($raw, true);
if (($o['status'] ?? '') !== 'processing') { http_response_code(200); exit; } // only paid orders

$ship = !empty($o['shipping']['address_1']) ? $o['shipping'] : $o['billing'];
$host = parse_url($_SERVER['HTTP_X_WC_WEBHOOK_SOURCE'] ?? '', PHP_URL_HOST);
$payload = [
    'external_id' => "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), find the WooCommerce order id in the order's external_id, then:

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:<store host>:<id>
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