Send online orders to SyncPay
One endpoint. Your server sends each paid order as JSON: the SKUs, the payment references and, once it ships, the tracking. SyncPay turns it into an itemised receipt for the merchant and the card issuer.
https://api.syncpay.au/v1/transactionsServer-side only
Never put your SyncPay key in browser JavaScript, a mobile app or a public repository. Anyone who can read it can write orders as your shop. Call the API from your backend, a webhook handler or your platform's server hooks.
Integer cents
9890 means 98.90. Convert once, at the edge of your code, with rounding, not truncation.
Same API for point of sale: developers-pos.html.
Keys
A shop's own key is pinned to that shop, so requests need no merchant id. Email [email protected] with the business name and ABN to get one.
| Key | Use |
|---|---|
spk_test_... | Sandbox. Nothing reaches a receipt, the merchant dashboard or an issuer. |
spk_live_... | Production. |
- A key is shown once. SyncPay stores only a hash, so a lost key is replaced, not recovered.
- Rotation: the new key works straight away and the old one keeps working for 24 hours.
- Revocation is immediate. Use it if a key leaks.
- Building for many shops (a platform or plugin)? You get one vendor key, send
merchant_refon every request, and each shop links your account first. See Keys and merchant links.
Quickstart
- Check the key.
shell
curl -sS https://api.syncpay.au/v1/auth \ -H "Authorization: Bearer spk_test_YOUR_TEST_KEY"json{ "object": "auth", "livemode": false, "partner": { "name": "Example Shop Pty Ltd", "kind": "merchant" }, "key": { "prefix": "spk_test_", "last4": "7c2e" }, "merchant_pinned": true, "linked_merchants": 1 } - Send the smallest valid order.
shell
curl -sS -X POST https://api.syncpay.au/v1/transactions \ -H "Authorization: Bearer spk_test_YOUR_TEST_KEY" \ -H "Content-Type: application/json" \ -d '{ "channel": "web", "transaction_id": "order_10433", "currency": "AUD", "total_cents": 2500, "created_at": "2026-10-02T03:10:00Z", "lines": [ { "sku": "CAP-NVY", "description": "Cap, navy", "quantity": 1, "unit_price_cents": 2500, "line_total_cents": 2500 } ], "payments": [ { "payment_id": "pay_10433_1", "amount_cents": 2500, "tender_type": "card", "gateway": "stripe", "gateway_payment_id": "pi_3Qk2mR2eZvKYlo2C0ghiJKL" } ] }' - Send a full order. Two SKUs, shipping, Apple Pay through Stripe.
shell
curl -sS -X POST https://api.syncpay.au/v1/transactions \ -H "Authorization: Bearer spk_test_YOUR_TEST_KEY" \ -H "Content-Type: application/json" \ -d '{ "channel": "web", "transaction_id": "order_10432", "type": "order", "status": "completed", "currency": "AUD", "subtotal_cents": 8991, "gst_cents": 899, "shipping_cents": 990, "discount_cents": 0, "total_cents": 9890, "created_at": "2026-10-02T03:02:11Z", "completed_at": "2026-10-02T03:02:14Z", "order_number": "10432", "site_url": "https://shop.example.com", "customer": { "customer_id": "cus_5571", "email": "[email protected]", "phone": "+61400000000", "name": "Dana Example", "consent": "tos_acceptance" }, "lines": [ { "line_id": "1", "sku": "TEE-BLK-M", "gtin": "9300000000017", "description": "Organic tee, black, M", "quantity": 2, "unit_price_cents": 3200, "line_total_cents": 6400, "gst_cents": 582, "category": "Apparel" }, { "line_id": "2", "sku": "CAP-NVY", "description": "Cap, navy", "quantity": 1, "unit_price_cents": 2500, "line_total_cents": 2500, "gst_cents": 227, "category": "Accessories" } ], "payments": [ { "payment_id": "pay_10432_1", "amount_cents": 9890, "tender_type": "card", "status": "captured", "paid_at": "2026-10-02T03:02:14Z", "gateway": "stripe", "gateway_payment_id": "pi_3Qk2mR2eZvKYlo2C0abcDEFG", "payment_reference": "ch_3Qk2mR2eZvKYlo2C0xyzABC", "avs_result": "pass", "cvv_result": "pass", "card": { "last4": "4242", "brand": "visa", "entry_mode": "ecommerce", "funding": "credit", "wallet": "apple_pay" } } ], "fulfilment": { "method": "ship", "status": "unfulfilled", "ship_to": { "name": "Dana Example", "line1": "12 Example Street", "line2": "Unit 3", "city": "Fitzroy", "state": "VIC", "postcode": "3065", "country": "AU" } }, "billing_address": { "name": "Dana Example", "line1": "12 Example Street", "city": "Fitzroy", "state": "VIC", "postcode": "3065", "country": "AU" } }'First time:
201 Created.json{ "id": "txn_0b9d4e4f8a9c6b2e5d8a917f306f1c2a", "object": "transaction", "livemode": false, "transaction_id": "order_10432", "merchant_ref": null, "version": 1, "created": true, "updated": false } - Add tracking when it ships. Re-send the same order with
fulfilmentfilled in. Response:200,version: 2.json{ "method": "ship", "status": "shipped", "carrier": "Australia Post", "service": "Parcel Post", "tracking_number": "33ABC1234567", "tracking_url": "https://auspost.com.au/mypost/track/details/33ABC1234567", "shipped_at": "2026-10-03T00:30:00Z", "ship_to": { "name": "Dana Example", "line1": "12 Example Street", "line2": "Unit 3", "city": "Fitzroy", "state": "VIC", "postcode": "3065", "country": "AU" } }json{ "id": "txn_0b9d4e4f8a9c6b2e5d8a917f306f1c2a", "object": "transaction", "livemode": false, "transaction_id": "order_10432", "merchant_ref": null, "version": 2, "created": false, "updated": true }
If the gateway route is not reachable from your network yet, the same endpoint answers at https://api.syncpay.au/functions/v1/syncpay-ingest/v1/transactions.
Website snippets
Node.js
Node 18 or later (built-in fetch). Map your own order object in toSyncPay; the field names on the left are the API's.
// Server-side only. Call this from the code that marks an order as paid,
// for example your Stripe payment_intent.succeeded webhook handler.
const SYNCPAY_URL = "https://api.syncpay.au/v1/transactions";
const SYNCPAY_KEY = process.env.SYNCPAY_KEY; // spk_test_... or spk_live_...
const cents = (dollars) => Math.round(Number(dollars) * 100);
export function toSyncPay(order) {
return {
channel: "web",
transaction_id: String(order.id),
type: "order",
status: order.refunded ? "refunded" : "completed",
currency: order.currency.toUpperCase(),
total_cents: cents(order.total),
gst_cents: cents(order.taxTotal),
subtotal_cents: cents(order.total) - cents(order.taxTotal),
shipping_cents: cents(order.shippingTotal),
created_at: order.createdAt,
completed_at: order.paidAt,
order_number: String(order.number),
site_url: "https://shop.example.com",
customer: { email: order.email, name: order.customerName },
lines: order.items.map((item, i) => ({
line_id: String(i + 1),
sku: item.sku,
description: item.name,
quantity: item.quantity,
unit_price_cents: cents(item.unitPrice),
line_total_cents: cents(item.lineTotal),
gst_cents: cents(item.tax),
})),
payments: [{
payment_id: order.paymentIntentId,
amount_cents: cents(order.total),
tender_type: "card",
gateway: "stripe",
gateway_payment_id: order.paymentIntentId,
paid_at: order.paidAt,
card: { last4: order.cardLast4, brand: order.cardBrand, entry_mode: "ecommerce" },
}],
...(order.tracking && {
fulfilment: {
method: "ship",
status: "shipped",
carrier: order.tracking.carrier,
tracking_number: order.tracking.number,
tracking_url: order.tracking.url,
shipped_at: order.tracking.shippedAt,
},
}),
};
}
// Same body is always safe to resend, so retry on 409, 429 and 5xx.
export async function sendToSyncPay(order, attempt = 1) {
const res = await fetch(SYNCPAY_URL, {
method: "POST",
headers: { Authorization: `Bearer ${SYNCPAY_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(toSyncPay(order)),
signal: AbortSignal.timeout(10_000),
});
if ((res.status === 409 || res.status === 429 || res.status >= 500) && attempt < 4) {
const wait = Number(res.headers.get("Retry-After") ?? 2 ** attempt);
await new Promise((r) => setTimeout(r, wait * 1000));
return sendToSyncPay(order, attempt + 1);
}
const body = await res.json();
if (!res.ok) throw new Error(`SyncPay ${res.status}: ${body.error?.type}`);
return body; // { id, version, created, updated, ... }
}
// Later, when the parcel ships: same order, now with tracking. Version goes up.
// await sendToSyncPay({ ...order, tracking: { carrier: "Australia Post", number: "33ABC1234567", url, shippedAt } });WooCommerce (PHP)
Hooks woocommerce_payment_complete and sends with wp_remote_post. Failed sends are retried by WP-Cron.
<?php
// WooCommerce: send each paid order to SyncPay. Put this in a small plugin or
// your theme's functions.php, and the key in wp-config.php:
// define('SYNCPAY_KEY', 'spk_test_...');
function syncpay_cents($amount) {
return (int) round((float) $amount * 100);
}
function syncpay_send_order($order_id, $fulfilment = null) {
$order = wc_get_order($order_id);
if (!$order || !defined('SYNCPAY_KEY')) {
return;
}
$lines = [];
foreach ($order->get_items() as $item_id => $item) {
$product = $item->get_product();
$qty = $item->get_quantity();
$line_tot = syncpay_cents($item->get_total() + $item->get_total_tax());
$lines[] = [
'line_id' => (string) $item_id,
'sku' => $product ? $product->get_sku() : null,
'description' => $item->get_name(),
'quantity' => $qty,
'unit_price_cents' => $qty ? (int) round($line_tot / $qty) : $line_tot,
'line_total_cents' => $line_tot,
'gst_cents' => syncpay_cents($item->get_total_tax()),
];
}
$total = syncpay_cents($order->get_total());
$tax = syncpay_cents($order->get_total_tax());
$paid = $order->get_date_paid();
$body = [
'channel' => 'web',
'transaction_id' => (string) $order->get_id(),
'type' => 'order',
'status' => $order->get_status() === 'refunded' ? 'refunded' : 'completed',
'currency' => $order->get_currency(),
'total_cents' => $total,
'gst_cents' => $tax,
'subtotal_cents' => $total - $tax,
'shipping_cents' => syncpay_cents($order->get_shipping_total() + $order->get_shipping_tax()),
'discount_cents' => syncpay_cents($order->get_discount_total() + $order->get_discount_tax()),
'created_at' => $order->get_date_created()->format(DATE_ATOM),
'completed_at' => $paid ? $paid->format(DATE_ATOM) : null,
'order_number' => (string) $order->get_order_number(),
'site_url' => home_url(),
'customer' => [
'email' => $order->get_billing_email(),
'phone' => $order->get_billing_phone(),
'name' => trim($order->get_billing_first_name() . ' ' . $order->get_billing_last_name()),
],
'lines' => $lines,
'payments' => [[
'payment_id' => $order->get_transaction_id() ?: 'wc_' . $order->get_id(),
'amount_cents' => $total,
'tender_type' => 'card',
'gateway' => $order->get_payment_method(),
'gateway_payment_id' => $order->get_transaction_id() ?: null,
'paid_at' => $paid ? $paid->format(DATE_ATOM) : null,
]],
];
if ($fulfilment) {
$body['fulfilment'] = $fulfilment;
}
$res = wp_remote_post('https://api.syncpay.au/v1/transactions', [
'headers' => [
'Authorization' => 'Bearer ' . SYNCPAY_KEY,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode($body),
'timeout' => 10,
]);
$code = is_wp_error($res) ? 0 : wp_remote_retrieve_response_code($res);
if ($code === 0 || $code === 409 || $code === 429 || $code >= 500) {
// Safe to retry later with the same order: SyncPay deduplicates on transaction_id.
$order->add_order_note('SyncPay send failed (' . $code . '), will retry.');
wp_schedule_single_event(time() + 300, 'syncpay_retry_order', [$order_id]);
} elseif ($code !== 200 && $code !== 201) {
// 4xx: retrying the same body will not help. Log it and fix the mapping.
$order->add_order_note('SyncPay rejected the order (' . $code . '): ' . wp_remote_retrieve_body($res));
}
}
add_action('woocommerce_payment_complete', 'syncpay_send_order');
add_action('syncpay_retry_order', 'syncpay_send_order');
// When you ship, resend the order with tracking. Where the tracking number
// lives depends on your shipping plugin; this reads two example meta keys.
add_action('woocommerce_order_status_completed', function ($order_id) {
$order = wc_get_order($order_id);
$tracking = $order ? $order->get_meta('_tracking_number') : '';
if (!$tracking) {
return;
}
syncpay_send_order($order_id, [
'method' => 'ship',
'status' => 'shipped',
'carrier' => $order->get_meta('_tracking_provider') ?: null,
'tracking_number' => $tracking,
'shipped_at' => gmdate(DATE_ATOM),
]);
});Request
Headers: Authorization: Bearer spk_... and Content-Type: application/json. Timestamps are ISO 8601 with an offset or Z. Amounts are integer cents and never negative.
Order
| Field | Type | Required | Notes |
|---|---|---|---|
channel | string | yes | web for online orders. |
transaction_id | string, max 128 | yes | Your order id. The idempotency key: unique per merchant and per mode. |
type | string | order for online orders, return for a matched return. Default sale. | |
status | string | open, completed, voided, refunded or partially_refunded. Default completed. | |
original_transaction_id | string | On a return, the transaction_id of the order it reverses. | |
currency | string | yes | ISO 4217, for example AUD. |
total_cents | integer | yes | What the customer paid, shipping included. |
subtotal_cents | integer | Amount before GST. | |
gst_cents | integer | GST included in the total. | |
shipping_cents | integer | Shipping charged, GST included. | |
discount_cents, tip_cents, refunded_cents | integer | Order-level discount, tip and amount refunded so far. | |
created_at | timestamp | yes | When the order was placed. |
completed_at | timestamp | When it was paid. | |
order_number | string | The order number the customer sees. | |
site_url | string | The storefront the order came from. | |
customer | object | customer_id, email, phone, name, consent. See Customer below. | |
lines | array | SKU-level line items. Up to 500. | |
payments | array | Payments against the order. Up to 20. | |
fulfilment | object | Shipping and tracking. See Fulfilment below. | |
billing_address | object | Same shape as fulfilment.ship_to. | |
merchant_ref | string | Only for platforms sending on behalf of many shops. Leave it out on your own shop's key. | |
metadata | object | Up to 20 keys, string values up to 500 characters. |
Line
| Field | Type | Required | Notes |
|---|---|---|---|
line_id | string | Your id for the line. | |
sku | string | Your stock keeping unit. | |
gtin | string | Barcode number (GTIN, EAN, UPC). | |
description | string | yes | What the customer sees on the receipt. |
quantity | number | yes | May be fractional, for example 1.25 kg. |
unit_price_cents | integer | yes | Price per unit in cents. |
line_total_cents | integer | yes | Total for the line in cents. |
gst_cents | integer | GST included in the line. | |
discount_cents | integer | Discount applied to the line. | |
category | string | Your product category. |
Payment
Payment references are what tie the receipt to the customer's card statement. Send the gateway's payment id at minimum, and any other reference you hold in payment_reference.
| Field | Type | Required | Notes |
|---|---|---|---|
payment_id | string | yes | Your id for this payment. Unique within the transaction. |
amount_cents | integer | yes | Amount of this payment in cents. |
tender_type | string | yes | card, eftpos, wallet, bnpl, bank_transfer, cash, account or other. |
status | string | authorized, captured, refunded, partially_refunded, voided or failed. Default captured. | |
paid_at | timestamp | When the payment was taken. | |
gateway | string | Processor or acquirer, for example stripe, tyro, square. | |
gateway_payment_id | string | The processor's id for the payment. | |
gateway_order_id | string | The processor's order id, if it has an order layer. | |
payment_reference | string | Any other reference that identifies the payment, for example a charge id or a bank transfer reference. | |
acquirer_reference_number | string | The ARN. Usually only known at settlement: send it later on a re-send. | |
arn_scheme | string | Card scheme the ARN belongs to, for example visa. | |
rrn | string | Retrieval reference number from the terminal. | |
stan | string | System trace audit number. Only unique per terminal per day, so send terminal_id and paid_at with it. | |
terminal_id | string | Payment terminal id. | |
settlement_batch_id | string | Settlement batch the payment closed in. | |
auth_code | string | Issuer authorisation code. | |
avs_result | string | Address verification result, as your gateway reports it. | |
cvv_result | string | CVV check result, as your gateway reports it. Never the CVV itself. | |
card | object | bin (6 or 8 digits; 6 for American Express and Diners Club), last4 (exactly 4), brand, entry_mode (chip, contactless, swipe, keyed, on_file, ecommerce), funding, wallet. |
Fulfilment
| Field | Notes |
|---|---|
method | ship, pickup, in_store or digital. |
status | unfulfilled, partial, shipped, delivered or returned. |
carrier | For example Australia Post. |
service | For example Express Post. |
tracking_number | The carrier's tracking number. |
tracking_url | Public tracking page. |
shipped_at, delivered_at | Timestamps. |
ship_to | name, line1, line2, city, state, postcode, country. |
Customer and consent
customer_id, email, phone and name are optional. Set consent to explicit_optin or tos_acceptance only when your checkout actually collected that agreement; without it, the details sit on the receipt but no customer profile is created. Shipping and billing addresses are personal information: send them only if your privacy policy covers sharing them with SyncPay.
Updates and retries
transaction_idis the idempotency key. Always send the full, current order, not a diff.- Same body again:
200, sameversion,updated: false. Retrying after a timeout or a5xxis always safe. - Changed body:
200,versiongoes up,updated: true. Use it for tracking, delivery, a cancellation (status: "voided") or a refund (status: "refunded"or"partially_refunded"withrefunded_cents).
Sandbox
Same URL, spk_test_ key. Test mode runs every check (authentication, validation, card-data refusal, idempotency, versioning) and stores the order for your testing only: no receipt, nothing in the merchant dashboard, nothing to an issuer, no charge. Responses carry "livemode": false. Test orders are deleted after 30 days.
Card data
Never send
The full card number (PAN), the CVV or CVC value, the expiry date, magnetic stripe track data, PIN blocks or any key material. Not in a field, not in metadata (whatever the key is called), not in a line description or a payment reference.
Fine to send
card.bin (first 6 or 8 digits), card.last4, the brand, the entry mode, the auth code and the AVS and CVV result codes.
SyncPay refuses card data rather than quietly dropping it. A field named like a card number, CVV, expiry, track or PIN, or a 13 to 19 digit number that passes the Luhn check in a free-text field, gets 400 card_data_rejected. The body is not stored and not logged. Keeping card data out keeps your PCI scope and ours unchanged.
Responses and errors
Success is 201 the first time a transaction_id is seen and 200 after that. The response is the same whether or not the order ends up on a receipt.
{
"error": {
"type": "invalid_request",
"message": "2 fields are invalid",
"fields": [
{
"field": "lines[0].quantity",
"message": "must be a number greater than 0"
},
{
"field": "currency",
"message": "is required"
}
]
}
}| Status | Type | Meaning |
|---|---|---|
| 400 | invalid_json | The body is not valid JSON. |
| 400 | invalid_request | One or more fields failed validation. fields lists each one with its path, for example lines[0].quantity. |
| 400 | card_data_rejected | The body carried card data SyncPay does not accept. Nothing was stored. See Card data. |
| 401 | invalid_api_key | Missing, unknown, revoked or expired key, or a suspended account. Always the same response, so it cannot be used to tell these apart. |
| 403 | merchant_not_linked | Live key, and no active link between your account and that merchant_ref. |
| 403 | merchant_ref_required | A vendor key was used without merchant_ref. |
| 404 | not_found | Unknown path. |
| 405 | method_not_allowed | Wrong HTTP method for the path. |
| 413 | payload_too_large | Body over 256 KB. (More than 500 lines or 20 payments is a 400 invalid_request.) |
| 415 | unsupported_media_type | Content-Type is not application/json. |
| 409 | conflict | Another request for the same transaction_id is being processed right now. Wait for Retry-After (a couple of seconds) and resend the same body. |
| 429 | rate_limited | Over 300 requests a minute on this key, or too many failed authentication attempts from your IP. Wait for the number of seconds in Retry-After, then resend the same body. |
| 500 | internal_error | Our fault. Resend the same body; it is safe. |
Limits
| Limit | Value |
|---|---|
| Request body | 256 KB |
| Lines per order | 500 |
| Payments per order | 20 |
transaction_id | 128 characters |
metadata | 20 keys, values up to 500 characters |
| Rate | 300 requests a minute per key |
The path carries /v1. New optional fields can appear in responses at any time, so ignore fields you do not know. Breaking changes get /v2.
Questions and keys: [email protected].