外観
ヘッドレスのストアで発行してもらう(Hydrogen など)
このページでやること: Hydrogen などで構築した独自のストア(ヘッドレスのストア)のマイページから、お客様がご自身で領収書・納品書・請求書を発行できるようにします。ストアのコードへの組み込みが必要なため、ストアを開発している方と一緒に進めてください。
Pro プラン以上でご利用いただけます
Basic プラン・無料プランでは公開キーを発行できません。開発ストアでは、有料プランをテスト課金(無料)でお試しいただけます。
仕組み
- お客様がマイページの注文で[領収書・請求書を発行する]をクリックします
- ストアのサーバーが、ログイン中のお客様の注文を Shopify から取得し、まるっと請求書の API を呼び出します
- お客様が宛名を入力して発行すると、マイページを開いたまま PDF がダウンロードされます
- 発行できる書類と条件は、4. お客様に発行してもらうの設定(お客様が新しく発行できる書類、領収書を発行する条件など)に従います
- 本人の注文かどうかは、注文の確認番号で照合します。確認番号はストアのサーバーが Customer Account API から取得するため、お客様が入力する必要はありません
- 発行済みの書類は、発行したときの内容でダウンロードされます
組み込みが難しい場合
Hydrogen のストアでも、サンクスページのブロックと注文状況ページのブロックはそのまま利用できます。注文状況ページのブロックは、マイページの注文から Shopify の注文状況ページへ移動したときに表示されます。設置の手順は4. お客様に発行してもらうをご覧ください。
1. 公開キーを発行する
- 設定の[自動・セルフ発行]タブを開きます
- 「ヘッドレスのストア(Hydrogen など)」の[公開キーを発行する]をクリックします
- 「公開キー」の横の[コピー]をクリックし、コピーした公開キーをストアのサーバーの環境変数
MARUTTO_INVOICE_KEYに設定します(「API の URL」の[コピー]ではありません)
公開キーはストアを識別するための値で、ストアのページに表示されても問題ありません。[キーを再発行する]をクリックすると古いキーは使えなくなるため、ストアの環境変数も新しいキーに差し替えてください。[無効にする]をクリックすると、お客様はマイページから発行できなくなります。
2. ストアに組み込む(Hydrogen)
Hydrogen の公式テンプレート(templates/react-router)に組み込む場合の例です。ファイルを2つ追加し、3つを変更します。
環境変数を読めるようにする
app/lib/env.ts の Env に1行追加します。
ts
export type Env = {
// …既存の項目
MARUTTO_INVOICE_KEY?: string;
};ルートを追加する
app/routes.ts の route("*", …) より前に1行追加します。
ts
route("account/documents", "routes/account.documents.tsx"),サーバーの処理(app/routes/account.documents.tsx)
ログイン中のお客様の注文を Customer Account API から取得し、その確認番号を添えてまるっと請求書の API を呼び出します。Customer Account API はログイン中のお客様の注文しか返さないため、ほかの人の注文 ID が送られてきても発行されません。
tsx
import { gql } from "@shopify/hydrogen/customer-account";
import { customerAccountContext } from "~/lib/customer-account";
import { envContext } from "~/lib/env";
import type { Route } from "./+types/account.documents";
const MARUTTO_API_URL = "https://marutto-invoice.fly.dev/api/headless/v1";
// ログイン中のお客様の注文だけが返ります(ほかの人の注文 ID を送られても読めません)
const ORDER_CONFIRMATION_QUERY = gql(`
query MaruttoOrderConfirmation($orderId: ID!) {
order(id: $orderId) {
id
confirmationNumber
}
}
`);
const ORDER_NOT_FOUND = { error: { code: "ORDER_NOT_FOUND", message: "ご注文が見つかりません。" } };
export async function action({ context, request }: Route.ActionArgs) {
const body = (await request.json().catch(() => null)) as {
intent?: string;
orderId?: string;
type?: string;
buyer?: { companyName?: string; department?: string; name?: string };
purpose?: string;
} | null;
if (!body?.orderId) return Response.json(ORDER_NOT_FOUND, { status: 404 });
const customerAccount = context.get(customerAccountContext);
if (!customerAccount) return Response.json(ORDER_NOT_FOUND, { status: 404 });
const { client, session, sessionManager, requestContext } = customerAccount;
const accessToken = await session.getOrRefreshAccessToken(sessionManager, requestContext);
if (!accessToken) return Response.json(ORDER_NOT_FOUND, { status: 401 });
const { data, errors } = await client.graphql(ORDER_CONFIRMATION_QUERY, {
accessToken,
variables: { orderId: body.orderId },
});
const order = data?.order;
if (errors || !order?.confirmationNumber) return Response.json(ORDER_NOT_FOUND, { status: 404 });
const ref = { orderId: order.id, confirmationNumber: order.confirmationNumber };
const path = body.intent === "issue" ? "/documents" : "/documents/availability";
const payload =
body.intent === "issue"
? { ...ref, type: body.type, buyer: body.buyer ?? {}, purpose: body.purpose || undefined }
: ref;
const response = await fetch(`${MARUTTO_API_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Marutto-Key": context.get(envContext).MARUTTO_INVOICE_KEY ?? "",
"Accept-Language": request.headers.get("Accept-Language") ?? "ja",
},
body: JSON.stringify(payload),
});
return Response.json(await response.json(), { status: response.status });
}画面の部品(app/components/OrderDocuments.tsx)
マイページの注文ごとに置く部品です。見た目はストアのデザインに合わせて調整してください。公式テンプレートの CSS のままでは、入力欄の枠やボタンのスタイルが付きません。
tsx
import { useState } from "react";
type DocumentType = "receipt" | "delivery_note" | "invoice";
type DocumentState = {
type: DocumentType;
status: "available" | "issued" | "unavailable";
message: string | null;
};
type Availability = { language: "ja" | "en"; defaultPurpose: string; documents: DocumentState[] };
type ApiError = { error: { code: string; message: string } };
const LABELS = {
ja: {
open: "領収書・請求書を発行する",
names: { receipt: "領収書", delivery_note: "納品書", invoice: "請求書" },
issue: (name: string) => `${name}を発行する`,
download: (name: string) => `${name}をダウンロード`,
companyName: "会社名",
department: "部署名",
name: "お名前",
purpose: "但し書き(領収書)",
},
en: {
open: "Get receipt or invoice",
names: { receipt: "receipt", delivery_note: "delivery note", invoice: "invoice" },
issue: (name: string) => `Issue ${name}`,
download: (name: string) => `Download ${name}`,
companyName: "Company name",
department: "Department",
name: "Name",
purpose: "Description (receipt)",
},
};
const NETWORK_ERROR = {
ja: "通信に失敗しました。しばらくしてから、もう一度お試しください。",
en: "Something went wrong. Please try again in a moment.",
};
async function post(body: object) {
const response = await fetch("/account/documents", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return (await response.json()) as Availability | { downloadUrl: string } | ApiError;
}
// language はストアの表示言語(発行できる書類を読み込むまでの表示に使います)
export function OrderDocuments({ orderId, language = "ja" }: { orderId: string; language?: "ja" | "en" }) {
const [availability, setAvailability] = useState<Availability | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [buyer, setBuyer] = useState({ companyName: "", department: "", name: "" });
const [purpose, setPurpose] = useState("");
async function load() {
setBusy(true);
try {
const result = await post({ intent: "availability", orderId });
if ("error" in result) return setError(result.error.message);
if ("documents" in result) {
setError(null);
setAvailability(result);
setPurpose((current) => current || result.defaultPurpose);
}
} catch {
setError(NETWORK_ERROR[availability?.language ?? language]);
} finally {
setBusy(false);
}
}
async function issue(type: DocumentType) {
setBusy(true);
try {
const result = await post({ intent: "issue", orderId, type, buyer, purpose });
if ("error" in result) return setError(result.error.message);
if ("downloadUrl" in result) {
setError(null);
// PDF はダウンロードとして開くので、ページは移動しません
window.location.assign(result.downloadUrl);
}
} catch {
setError(NETWORK_ERROR[availability?.language ?? language]);
return;
} finally {
setBusy(false);
}
await load(); // 発行済みの表示に更新します
}
if (!availability) {
return (
<div>
<button type="button" onClick={load} disabled={busy}>
{LABELS[language].open}
</button>
{error ? <p role="alert">{error}</p> : null}
</div>
);
}
const t = LABELS[availability.language];
const visible = availability.documents.filter((doc) => doc.status !== "unavailable" || doc.message);
const needsBuyer = visible.some((doc) => doc.status === "available");
return (
<div>
{needsBuyer ? (
<fieldset>
<label>
{t.companyName}
<input value={buyer.companyName} onChange={(e) => setBuyer({ ...buyer, companyName: e.target.value })} />
</label>
<label>
{t.department}
<input value={buyer.department} onChange={(e) => setBuyer({ ...buyer, department: e.target.value })} />
</label>
<label>
{t.name}
<input value={buyer.name} onChange={(e) => setBuyer({ ...buyer, name: e.target.value })} />
</label>
<label>
{t.purpose}
<input value={purpose} onChange={(e) => setPurpose(e.target.value)} />
</label>
</fieldset>
) : null}
<ul>
{visible.map((doc) => (
<li key={doc.type}>
{doc.status === "unavailable" ? (
<span>{doc.message}</span>
) : (
<button type="button" onClick={() => issue(doc.type)} disabled={busy}>
{doc.status === "issued" ? t.download(t.names[doc.type]) : t.issue(t.names[doc.type])}
</button>
)}
</li>
))}
</ul>
{error ? <p role="alert">{error}</p> : null}
</div>
);
}マイページの注文に置く
app/routes/account.tsx で、注文の一覧(orders.nodes.map(…) の <li>)の中に追加します。英語で表示するストアでは language="en" も渡してください。language は、発行できる書類を読み込むまでの表示に使います。読み込んだあとは、お客様の言語で表示されます。
tsx
import { OrderDocuments } from "~/components/OrderDocuments";
// …注文ごとの <li> の中
<OrderDocuments orderId={order.id} />3. 動作を確認する
- ストアにお客様としてログインし、支払い済みの注文があるマイページを開きます
- [領収書・請求書を発行する]をクリックし、発行できる書類が表示されることを確認します
- 宛名を入力して[領収書を発行する]をクリックし、PDF がダウンロードされることを確認します
- 発行したあとは、ボタンが[領収書をダウンロード]に変わることを確認します。発行済みの書類は、発行したときの内容でダウンロードされます
書類が表示されない場合は、4. お客様に発行してもらうの「お客様が新しく発行できる書類」で書類を選んでいるか、発行者情報(会社名と登録番号)を入力しているかをご確認ください。
API リファレンス
Hydrogen 以外のフレームワークで組み込む場合は、次の仕様で呼び出してください。
- URL:
https://marutto-invoice.fly.dev/api/headless/v1 - エンドポイントは2つで、どちらも
POST、本文は JSON(16KB まで)です - ヘッダーの
X-Marutto-Keyに公開キーを、Accept-Languageにお客様のブラウザの値を設定します - ブラウザから直接呼び出すこともできます(CORS は
*、Cookie は使いません)。ただし、その場合は確認番号をブラウザに渡すことになるため、ストアのサーバーから呼び出すことをおすすめします
発行できる書類を取得する
POST /documents/availability
リクエストの本文は次のとおりです。
json
{ "orderId": "gid://shopify/Order/123", "confirmationNumber": "ABC123XYZ" }orderId には Customer Account API の Order.id を、confirmationNumber には Order.confirmationNumber を指定します。レスポンスは次のとおりです。
json
{
"order": { "name": "#1001" },
"language": "ja",
"defaultPurpose": "お品代として",
"documents": [
{ "type": "receipt", "status": "available", "message": null },
{ "type": "delivery_note", "status": "issued", "message": null },
{ "type": "invoice", "status": "unavailable", "message": "請求書は発行できません。" }
]
}status | 意味 | 画面での扱い |
|---|---|---|
available | 新しく発行できます | お客様に宛名を入力していただき、発行します |
issued | 発行済みです | 宛名を入力せずにダウンロードします |
unavailable | 今は発行できません | message を表示します。message が null なら、その書類は表示しません |
documents には、領収書・納品書・請求書の順に3つとも入ります。message は Shopify の注文に記録されたお客様の言語(日本語か英語)で返し、その言語を language に入れます。注文に記録される言語は、お客様がチェックアウトしたときの言語です。Hydrogen の公式テンプレートは既定の言語が英語のため、そのままでは文言が英語になります。日本語で表示するには、ストア(Hydrogen)の言語を日本語に設定してください。なお、PDF の書類の言語は、まるっと請求書の設定「海外のお客様の書類を英語で発行する」で決まります。
書類を発行する
POST /documents
リクエストの本文は次のとおりです。
json
{
"orderId": "gid://shopify/Order/123",
"confirmationNumber": "ABC123XYZ",
"type": "receipt",
"buyer": { "companyName": "", "department": "", "name": "山田 太郎" },
"purpose": "お品代として"
}type には receipt・delivery_note・invoice のいずれかを指定します。buyer(宛名)と purpose(領収書の但し書き)は省略できます。発行済みの書類では、どちらも使いません。レスポンスは次のとおりです。
json
{
"type": "receipt",
"documentNumber": "RC-20261107-12000512345",
"newlyIssued": true,
"filename": "receipt-RC-20261107-12000512345.pdf",
"downloadUrl": "https://marutto-invoice.fly.dev/api/public/self-issue-download?token=…",
"expiresAt": "2026-11-07T12:05:00.000Z"
}downloadUrl は、expiresAt の時刻(発行から約5分後)まで何度でも開けます。
エラー
エラーのときは、次の形で返します。
json
{ "error": { "code": "ORDER_NOT_FOUND", "message": "ご注文が見つかりません。…" } }message は、お客様にそのまま表示できる文です。
| HTTP | code | 主な原因 |
|---|---|---|
| 400 | INVALID_REQUEST | 本文の形式、または書類の種類が正しくない |
| 405 | INVALID_REQUEST | POST 以外のメソッドで呼び出した |
| 401 | INVALID_KEY | 公開キーが設定されていない、正しくない、または無効にされている |
| 403 | PLAN_REQUIRED | ストアのプランが Pro プラン以上ではない |
| 404 | ORDER_NOT_FOUND | 注文が見つからない・確認番号が正しくない |
| 409 | DOCUMENT_UNAVAILABLE | その書類を今は発行できない(理由は message) |
| 429 | TOO_MANY_REQUESTS | 呼び出しの回数が上限に達した(Retry-After の秒数が経ってから、もう一度お試しください) |
| 500 | INTERNAL_ERROR | 一時的に処理できない |
互換性
/v1 の項目は、変更も削除もしません。ただし、項目を追加することはあるため、知らない項目は無視するように実装してください。形式を変える必要が生じた場合は /v2 を用意し、/v1 は提供を続ける期間をお知らせしたうえで残します。