Developer docs · Websites

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.

POSThttps://api.syncpay.au/v1/transactions

Server-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.

KeyUse
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_ref on every request, and each shop links your account first. See Keys and merchant links.

Quickstart

  1. 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
    }
  2. 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"
        }
      ]
    }'
  3. 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
    }
  4. Add tracking when it ships. Re-send the same order with fulfilment filled 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.

javascript
// 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
<?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

FieldTypeRequiredNotes
channelstringyesweb for online orders.
transaction_idstring, max 128yesYour order id. The idempotency key: unique per merchant and per mode.
typestringorder for online orders, return for a matched return. Default sale.
statusstringopen, completed, voided, refunded or partially_refunded. Default completed.
original_transaction_idstringOn a return, the transaction_id of the order it reverses.
currencystringyesISO 4217, for example AUD.
total_centsintegeryesWhat the customer paid, shipping included.
subtotal_centsintegerAmount before GST.
gst_centsintegerGST included in the total.
shipping_centsintegerShipping charged, GST included.
discount_cents, tip_cents, refunded_centsintegerOrder-level discount, tip and amount refunded so far.
created_attimestampyesWhen the order was placed.
completed_attimestampWhen it was paid.
order_numberstringThe order number the customer sees.
site_urlstringThe storefront the order came from.
customerobjectcustomer_id, email, phone, name, consent. See Customer below.
linesarraySKU-level line items. Up to 500.
paymentsarrayPayments against the order. Up to 20.
fulfilmentobjectShipping and tracking. See Fulfilment below.
billing_addressobjectSame shape as fulfilment.ship_to.
merchant_refstringOnly for platforms sending on behalf of many shops. Leave it out on your own shop's key.
metadataobjectUp to 20 keys, string values up to 500 characters.

Line

FieldTypeRequiredNotes
line_idstringYour id for the line.
skustringYour stock keeping unit.
gtinstringBarcode number (GTIN, EAN, UPC).
descriptionstringyesWhat the customer sees on the receipt.
quantitynumberyesMay be fractional, for example 1.25 kg.
unit_price_centsintegeryesPrice per unit in cents.
line_total_centsintegeryesTotal for the line in cents.
gst_centsintegerGST included in the line.
discount_centsintegerDiscount applied to the line.
categorystringYour 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.

FieldTypeRequiredNotes
payment_idstringyesYour id for this payment. Unique within the transaction.
amount_centsintegeryesAmount of this payment in cents.
tender_typestringyescard, eftpos, wallet, bnpl, bank_transfer, cash, account or other.
statusstringauthorized, captured, refunded, partially_refunded, voided or failed. Default captured.
paid_attimestampWhen the payment was taken.
gatewaystringProcessor or acquirer, for example stripe, tyro, square.
gateway_payment_idstringThe processor's id for the payment.
gateway_order_idstringThe processor's order id, if it has an order layer.
payment_referencestringAny other reference that identifies the payment, for example a charge id or a bank transfer reference.
acquirer_reference_numberstringThe ARN. Usually only known at settlement: send it later on a re-send.
arn_schemestringCard scheme the ARN belongs to, for example visa.
rrnstringRetrieval reference number from the terminal.
stanstringSystem trace audit number. Only unique per terminal per day, so send terminal_id and paid_at with it.
terminal_idstringPayment terminal id.
settlement_batch_idstringSettlement batch the payment closed in.
auth_codestringIssuer authorisation code.
avs_resultstringAddress verification result, as your gateway reports it.
cvv_resultstringCVV check result, as your gateway reports it. Never the CVV itself.
cardobjectbin (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

FieldNotes
methodship, pickup, in_store or digital.
statusunfulfilled, partial, shipped, delivered or returned.
carrierFor example Australia Post.
serviceFor example Express Post.
tracking_numberThe carrier's tracking number.
tracking_urlPublic tracking page.
shipped_at, delivered_atTimestamps.
ship_toname, 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_id is the idempotency key. Always send the full, current order, not a diff.
  • Same body again: 200, same version, updated: false. Retrying after a timeout or a 5xx is always safe.
  • Changed body: 200, version goes up, updated: true. Use it for tracking, delivery, a cancellation (status: "voided") or a refund (status: "refunded" or "partially_refunded" with refunded_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.

json
{
  "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"
      }
    ]
  }
}
StatusTypeMeaning
400invalid_jsonThe body is not valid JSON.
400invalid_requestOne or more fields failed validation. fields lists each one with its path, for example lines[0].quantity.
400card_data_rejectedThe body carried card data SyncPay does not accept. Nothing was stored. See Card data.
401invalid_api_keyMissing, unknown, revoked or expired key, or a suspended account. Always the same response, so it cannot be used to tell these apart.
403merchant_not_linkedLive key, and no active link between your account and that merchant_ref.
403merchant_ref_requiredA vendor key was used without merchant_ref.
404not_foundUnknown path.
405method_not_allowedWrong HTTP method for the path.
413payload_too_largeBody over 256 KB. (More than 500 lines or 20 payments is a 400 invalid_request.)
415unsupported_media_typeContent-Type is not application/json.
409conflictAnother request for the same transaction_id is being processed right now. Wait for Retry-After (a couple of seconds) and resend the same body.
429rate_limitedOver 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.
500internal_errorOur fault. Resend the same body; it is safe.

Limits

LimitValue
Request body256 KB
Lines per order500
Payments per order20
transaction_id128 characters
metadata20 keys, values up to 500 characters
Rate300 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].