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:
define('PRINT_API_KEY', 'sk_live_…'); // sk_test_… on staging
define('PRINT_WEBHOOK_SECRET', 'whsec_…'); // secret of your webhook endpoint<?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_actionis Action Scheduler, which ships with WooCommerce.external_idis 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 with409 idempotency_key_reused.- States: WooCommerce stores ISO state codes for countries that have them (US, CA, AU, …), which is what
state_codeexpects. - If customers choose a faster shipping method, map
$order->get_shipping_methods()(method ids/titles) toexpressorpriority.
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
$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_idthanks toexternal_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:
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 |