Skip to content

ヘッドレスのストアで発行してもらう(Hydrogen など) ​

このページでやること: Hydrogen などで構築した独自のストア(ヘッドレスのストア)のマイページから、お客様がご自身で領収書・納品書・請求書を発行できるようにします。ストアのコードへの組み込みが必要なため、ストアを開発している方と一緒に進めてください。

Pro プラン以上でご利用いただけます

Basic プラン・無料プランでは公開キーを発行できません。開発ストアでは、有料プランをテスト課金(無料)でお試しいただけます。

仕組み ​

  1. お客様がマイページの注文で[領収書・請求書を発行する]をクリックします
  2. ストアのサーバーが、ログイン中のお客様の注文を Shopify から取得し、まるっと請求書の API を呼び出します
  3. お客様が宛名を入力して発行すると、マイページを開いたまま PDF がダウンロードされます
  • 発行できる書類と条件は、4. お客様に発行してもらうの設定(お客様が新しく発行できる書類、領収書を発行する条件など)に従います
  • 本人の注文かどうかは、注文の確認番号で照合します。確認番号はストアのサーバーが Customer Account API から取得するため、お客様が入力する必要はありません
  • 発行済みの書類は、発行したときの内容でダウンロードされます

組み込みが難しい場合

Hydrogen のストアでも、サンクスページのブロックと注文状況ページのブロックはそのまま利用できます。注文状況ページのブロックは、マイページの注文から Shopify の注文状況ページへ移動したときに表示されます。設置の手順は4. お客様に発行してもらうをご覧ください。

1. 公開キーを発行する ​

  1. 設定の[自動・セルフ発行]タブを開きます
  2. 「ヘッドレスのストア(Hydrogen など)」の[公開キーを発行する]をクリックします
  3. 「公開キー」の横の[コピー]をクリックし、コピーした公開キーをストアのサーバーの環境変数 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. 動作を確認する ​

  1. ストアにお客様としてログインし、支払い済みの注文があるマイページを開きます
  2. [領収書・請求書を発行する]をクリックし、発行できる書類が表示されることを確認します
  3. 宛名を入力して[領収書を発行する]をクリックし、PDF がダウンロードされることを確認します
  4. 発行したあとは、ボタンが[領収書をダウンロード]に変わることを確認します。発行済みの書類は、発行したときの内容でダウンロードされます

書類が表示されない場合は、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 は、お客様にそのまま表示できる文です。

HTTPcode主な原因
400INVALID_REQUEST本文の形式、または書類の種類が正しくない
405INVALID_REQUESTPOST 以外のメソッドで呼び出した
401INVALID_KEY公開キーが設定されていない、正しくない、または無効にされている
403PLAN_REQUIREDストアのプランが Pro プラン以上ではない
404ORDER_NOT_FOUND注文が見つからない・確認番号が正しくない
409DOCUMENT_UNAVAILABLEその書類を今は発行できない(理由は message)
429TOO_MANY_REQUESTS呼び出しの回数が上限に達した(Retry-After の秒数が経ってから、もう一度お試しください)
500INTERNAL_ERROR一時的に処理できない

互換性 ​

/v1 の項目は、変更も削除もしません。ただし、項目を追加することはあるため、知らない項目は無視するように実装してください。形式を変える必要が生じた場合は /v2 を用意し、/v1 は提供を続ける期間をお知らせしたうえで残します。