自社のオンラインショップをSquareで作ったときの話です。


送料を含めた合計金額は、きちんと合っている。テストの決済も通る。ところが本番の決済ページを開いてみたら、「送料」という項目がどこにもありませんでした。
しかも「ご注文内容(2点)」と、買っていないはずの点数まで増えている。
原因は、送料を入れる場所を間違えていたことでした。
何が起きていたか

やっていたのは、こういうことです。
商品を line_items(明細行)に入れて、そこに送料880円を「1行」として足していました。合計金額は正しく出ます。1,650円の商品なら、2,530円になる。計算は合っている。
ただしSquare側から見ると、明細行が2つある注文になっています。送料は「880円の商品をもう1つ買った」のと同じ扱いです。
実際の決済ページには、こう出ました。
お会計 ¥2,530
ご注文内容(2点)
小計 ¥2,530
注文合計 ¥2,530
送料の行がありません。 小計に飲み込まれています。そして買ったのは1点なのに、2点と表示される。
さらに、住所を入力して配送方法を選ぶところまで進むと、「送料無料」と表示されました。Square側の配送料フィールドは0円のままなので、当然といえば当然です。
お客様の目には、こう映ります。
- 送料がいくらか書いていない
- 送料は無料と書いてあるのに、合計は2,530円
- 注文点数が2点だが、買ったのは1点
金額が合っているだけでは、検証になっていなかったということです。ここは反省しました。
正しい入れ方は checkout_options.shipping_fee
答えを先に書きます。送料は line_items ではなく、checkout_options の中の shipping_fee に入れます。
{
"idempotency_key": "…",
"order": {
"location_id": "…",
"line_items": [
{ "name": "コースター 4枚セット", "quantity": "1",
"base_price_money": { "amount": 1650, "currency": "JPY" } }
]
},
"checkout_options": {
"shipping_fee": {
"name": "送料(茨城県)",
"charge": { "amount": 880, "currency": "JPY" }
},
"ask_for_shipping_address": true
}
}
こう書くと、送料は「service charge(SUBTOTAL_PHASE)」として計上されます。同じ内容で決済リンクを作り直すと、表示がこうなりました。
コースター 4枚セット ¥2,530
ご注文内容(1点)
小計 ¥1,650
送料(茨城県) ¥880
注文合計 ¥2,530
送料が独立した行として出ます。 点数も1点に戻りました。商品名も表示されるようになっています。
同じ2,530円でも、見え方はまったく別物です。
PHPで書くと、こんな形になります。実際に動いているものを、そのまま短くしたものです。
<?php
function create_payment_link(array $items, int $shippingFee, string $shippingName): array
{
$lineItems = [];
foreach ($items as $it) {
$lineItems[] = [
'name' => $it['name'],
'quantity' => (string)$it['qty'],
'base_price_money' => ['amount' => $it['price'], 'currency' => 'JPY'],
];
}
$body = [
'idempotency_key' => bin2hex(random_bytes(16)),
'order' => [
'location_id' => SQUARE_LOCATION_ID,
'line_items' => $lineItems,
],
'checkout_options' => [
'ask_for_shipping_address' => true,
],
];
// 送料は line_items ではなく checkout_options に入れる
if ($shippingFee > 0) {
$body['checkout_options']['shipping_fee'] = [
'name' => $shippingName,
'charge' => ['amount' => $shippingFee, 'currency' => 'JPY'],
];
}
$ch = curl_init('https://connect.squareup.com/v2/online-checkout/payment-links');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Square-Version: 2026-08-20',
'Authorization: Bearer ' . SQUARE_ACCESS_TOKEN,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE),
CURLOPT_TIMEOUT => 20,
]);
$res = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
// 決済リンクが作れなかったときは、金額を表示したまま止める
throw new RuntimeException('Square payment link error: ' . $res);
}
return json_decode($res, true)['payment_link'];
}
ポイントは2つだけです。
shipping_fee を checkout_options の中に置くこと。そして Square-Version ヘッダーでAPIのバージョンを固定すること。バージョンを指定しないとアカウントの既定値が使われるので、ある日いきなり挙動が変わることがあります。日付を書いておくと、そこで固定されます。
idempotency_key は毎回ランダムで問題ありません。同じキーを再送すると同じリンクが返る仕組みなので、通信エラーで二重に作られるのを防いでくれます。
JPYはゼロ十進通貨、という落とし穴
もうひとつ、最初に踏みかけたところです。
amount に入れる数字は、円そのままでいいです。330と書けば330円になります。
海外のサンプルコードを見ていると、USDで「$3.30 なら 330」と書いてあります。これはドルがセント単位、つまり100分の1まで扱う通貨だからです。
日本円は「ゼロ十進通貨」といって、その下の単位がありません。なので円をそのまま書くのが正解です。ここで100倍して書いてしまうと、330円のつもりが33,000円の請求になります。
サンプルをそのまま持ってくると事故る場所なので、最初に確認しておくと安心です。
Sandboxでは気づけない
ここがいちばん伝えたいところかもしれません。
Squareには開発用のSandbox環境があります。ところがSandboxで作った決済リンクを開くと、Squareの「Testing Panel」に飛びます。実際の決済ページのUIではありません。
つまり、
- 送料の行がどう見えるか
- 注文点数がどう出るか
- 3Dセキュアの画面がどう挟まるか
これらは本番でしか確認できません。私が送料の行が出ていないことに気づいたのも、本番で決済リンクを作ってからでした。
では本番で試すと在庫や売上が汚れるのか、というと、そこは手順で避けられます。
CreatePaymentLinkでリンクを作る- リンクを開いて、実際の決済ページを目で見る
GET /v2/orders/{orderId}で金額の内訳を確認するDeletePaymentLinkで即削除する
決済を完了させなければ在庫は動きません。リンクも消えます。この4ステップなら、本番環境でも安全に確認できます。
「実際の決済ページを目で見る」を工程に入れておくのが、いちばん確実だと思います。
地域別の送料は、静的なリンクでは扱えない
都道府県によって送料が変わる設計にする場合、Squareの管理画面で作る「固定の決済リンク」では対応できません。
shipping_fee は注文ごとに値が変わるので、注文のたびに CreatePaymentLink でリンクを生成する必要があります。
つまりこうなります。
- サイト側で送り先の地域を選んでもらう
- 地域から送料を計算する
- その金額を入れて決済リンクを作る
- そのリンクへお客様を飛ばす
ここまで来ると、「Squareの管理画面でリンクを作って貼る」という運用では足りなくなります。PHPなりで中継する仕組みが必要です。
逆に言うと、全国一律送料にできるなら、実装はかなり楽になります。ここは設計の最初に決めておくのがいいと思います。
それでもSquareを選ぶ理由
書いてきたことだけ読むと、面倒な印象を持たれるかもしれません。それでもSquareを選んだ理由があります。
商品と在庫と価格を、1か所にまとめられるからです。
うちはイベント出店を見据えてSquareに登録しました(まだ出店はできていません)。対面で売るならSquareの端末を使うことになる。そのとき、サイト側とイベント側で在庫を別々に管理したくなかった。
なので、Squareを在庫と価格の「正」にして、サイト側は表示だけを担当する構成にしました。商品を追加するときも、価格を変えるときも、触るのはSquareだけです。サイトは何もしなくていい。
すでに実店舗でSquareをお使いの事業者さんなら、同じ考え方がそのまま使えると思います。商品も在庫もすでにSquareの中にあるので、ECを足すときの手間がいちばん小さくなります。
それに、日本のオンラインカード決済は3Dセキュアが必須で、加盟店側で無効にできません。自前で決済フォームを作ると、本人認証の実装まで自分でやることになります。
ホスト型の決済リンクを使えば、3DSもカード情報の取り扱いもSquare側で完結します。ここは正直、かなり楽です。
まとめ
- 送料は
line_itemsではなくcheckout_options.shipping_feeに入れる - 日本円はそのままの数字。100倍しない
- Sandboxでは決済ページの見た目を確認できない。本番で作って、目で見て、消す
- 地域別送料にするなら、決済リンクは注文ごとに動的生成する
金額が合っていても、画面が間違っていることがあります。ECは1円のずれが信用に直結する場所なので、最後は必ず自分の目で見る。これに尽きるなと思いました。
同じところで詰まっている方の役に立てば幸いです。