Zoho Campaigns APIで下書きキャンペーンを作るとは、createCampaign を呼び出して、件名・差出人・宛先リスト・本文を設定した「送信前のキャンペーン」をZoho Campaigns上に作ることです。作られたキャンペーンは画面の一覧に下書き(Draft)として並び、内容を確かめてから画面で送信できます。

当社(株式会社etika)は、自社のセミナー告知メールをAIエージェントとスクリプトで下書きまで作り、送信だけは人が画面で行っています。この記事は、その仕組みで公式ドキュメントどおりには動かなかった点と、動いている手順をまとめたものです。内容は2026年7〜10月に当社の環境で確かめた結果です。手順4のコード例は、当社の環境で2026年10月8日に動作を確認したコードです(送信元・本文URL・リストキー・トピックIDは当社の値に置き換えて実行しました)。お使いの環境でも、まずテスト用のリストで動作を確認してからご利用ください。

AIに配信をどこまで任せるかの判断は、etika.lifeの「メルマガ作成をAIに任せる範囲」に書きました。

全体の流れ

トークンの取得、宛先リストの確認、本文HTMLの公開、createCampaignでの下書き作成までをプログラムが行い、最後の送信だけは人が画面で確かめて行う流れを示した図。

下書きを1本作るまでの流れは次の5段階です。

  1. Refresh Tokenを用意し、アクセストークンを取る
  2. 宛先リストのキー(listkey)とトピックID(topicId)を調べる
  3. 本文HTMLを、Zohoから読める公開URLに置く
  4. createCampaign をリクエストボディで呼ぶ
  5. 下書きができたこと、本文が取り込まれたことを確かめる

APIの仕様は公式の「Create Campaign(Zoho Campaigns Developer Help)」が出発点です。以下では、公式の説明と当社の実機で違った点を、各段階の中で書きます。

手順1:Refresh Tokenでアクセストークンを取る

Zoho CRMのAPIは、Self ClientのClient Credentials(ブラウザでの承認なしにトークンを発行する方式)で使えます(「Zoho APIをスクリプトから叩く最短ルート」)。Campaignsでは、これが通りませんでした。CRMで成功している組織IDで soid=ZohoCampaigns.<組織ID> を指定すると、トークンの発行が no_org を返します。2026年10月8日にも同じ結果でした。そのため当社は、Campaigns用のRefresh TokenをCRM用とは別に持っています。

発行の手順は次のとおりです。

  1. API Console(api-console.zoho.com)で既存のSelf Clientを開き、Generate Codeを選ぶ
  2. Scopeに必要なスコープをカンマ区切りで入れる。当社の用途では ZohoCampaigns.campaign.CREATE,ZohoCampaigns.campaign.UPDATE,ZohoCampaigns.campaign.READ,ZohoCampaigns.contact.READ
  3. 表示された認可コードを、数分以内に https://accounts.zoho.com/oauth/v2/token へ grant_type=authorization_code で送り、Refresh Tokenに交換する

Refresh Tokenのスコープは発行時に固定されます。スコープを足すときは、コードを発行し直します。スコープの違うトークンを使い回すと INVALID_OAUTHSCOPE になるため、当社はトークンのキャッシュもサービスごとに分けています。

手順2:listkeyとtopicIdを調べる

宛先にするメーリングリストのキーは getmailinglists、トピックIDは topics で取れます。

ここで1つ目の落とし穴があります。トピック管理の新しい版を有効にしている組織では、topicId が必須です。公式ドキュメントにも注記がありますが、パラメータ一覧だけを見ていると見落としがちです。当社の組織はトピック管理が有効でトピックが3つあるため、毎回明示して指定しています。トピックが1つしかない組織なら、topics の結果から自動で選ぶ作りにもできます。

もう1つは、code 6606 のエラーです。公式のエラー一覧での定義は「No lists selected for this campaign(リストが選ばれていない)」ですが、当社の記録では、購読者が0件のリストを宛先にしたときにもこのエラーで作成できませんでした。2026年10月8日には、連絡先が0件のセグメントを segments で指定したときに、同じ 6606 が「Selected list/segment doesnot contain any contacts」というメッセージで返りました。テスト用に空のリストを作って試すと、ここで止まることがあります。購読者が1件以上あるリストで試してください。

手順3:本文HTMLを公開URLに置く

createCampaign には、本文のHTMLそのものを渡すパラメータがありません。content_url に公開されたHTMLのURLを渡し、Zohoがそこから取り込みます。

当社は本文HTMLを、Cloudflare(Workersの静的アセット)の一時的な置き場に公開してから渡しています。注意点は2つです。

  • 取り込まれるまで、URLを知っていれば誰でも読める状態になる。推測されにくい名前にし、noindex を付け、配信後に置き場ごと削除する
  • 取り込む前に、そのURLが本当に本文を返しているかを curl で確かめる。当社では、公開先の設定ミスで中身が空のURLを渡してしまい、本文が空の下書きができたことがある

手順4:createCampaignはリクエストボディで呼ぶ

ここが、公式ドキュメントと最も違った点です。公式のサンプルリクエストは、パラメータをURLのクエリ文字列に並べた形で書かれています。当社の環境でこの形のまま createCampaign を呼ぶと、JSONですらないHTMLのエラーページ(HTTP 500)が返りました。

同じパラメータを application/x-www-form-urlencoded のリクエストボディで渡すと、正常に作成されます。このContent-Typeは公式ドキュメントのヘッダー欄にも書かれています。

次のコードは、当社の実装を1ファイルにまとめた最小の例です。

// create-draft.mjs(Node.js 18以上)
const DC = "com"; // 日本DCなら "jp"
const ACCOUNTS = `https://accounts.zoho.${DC}`;
const CAMPAIGNS = `https://campaigns.zoho.${DC}`;

async function getAccessToken() {
  const res = await fetch(`${ACCOUNTS}/oauth/v2/token`, {
    method: "POST",
    body: new URLSearchParams({
      grant_type: "refresh_token",
      client_id: process.env.ZOHO_CLIENT_ID,
      client_secret: process.env.ZOHO_CLIENT_SECRET,
      refresh_token: process.env.ZOHO_CAMPAIGNS_REFRESH_TOKEN,
    }),
  });
  const json = await res.json();
  if (!json.access_token) throw new Error(`token error: ${JSON.stringify(json)}`);
  return json.access_token;
}

async function campaigns(token, path, params = {}, method = "GET") {
  const payload = new URLSearchParams({ resfmt: "JSON", ...params });
  const url = `${CAMPAIGNS}/api/v1.1/${path}`;
  const isPost = method === "POST";
  const res = await fetch(isPost ? url : `${url}?${payload}`, {
    method,
    headers: {
      Authorization: `Zoho-oauthtoken ${token}`,
      ...(isPost ? { "content-type": "application/x-www-form-urlencoded" } : {}),
    },
    body: isPost ? payload : undefined, // POSTはクエリでなくボディで渡す
  });
  const text = await res.text();
  let json;
  try { json = JSON.parse(text); } catch { throw new Error(`non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`); }
  // 成功の形が確認できたもの以外は失敗とみなす
  const code = String(json.code ?? json.Code ?? "");
  const failed = !res.ok || json.status === "error" || json.error != null || (code && !["0", "200"].includes(code));
  if (failed) throw new Error(`Campaigns API error: ${JSON.stringify(json)}`);
  return json;
}

const token = await getAccessToken();
const created = await campaigns(token, "createCampaign", {
  campaignname: "2026-10 セミナー告知(下書き)",
  from_email: "YOUR_VERIFIED_SENDER@example.com", // Campaignsで認証済みの送信元
  from_name: "株式会社サンプル",
  subject: "【10/16(金)・無料】セミナーのご案内",
  content_url: "https://YOUR_PUBLIC_HTML_URL/mail.html",
  list_details: JSON.stringify({ YOUR_LIST_KEY: [] }), // [] でリスト全体
  topicId: "YOUR_TOPIC_ID",
}, "POST");
console.log(created.campaignKey ?? created.campaign_key);

成功と失敗の判定を自前で書いているのは、応答の形がそろっていないためです。HTTP 200なのに本文が {"status":"error","Code":"INVALID_OAUTHSCOPE"} の失敗や、{"code":6606,"error":...}、HTMLのエラーページなど、形がまちまちでした。HTTPステータスだけを見ると、失敗を成功と取り違えます。

createCampaign がJSONではなくHTMLの断片を返したときは、注意が要ります。当社の環境では、この場合でも下書き自体は作られていて、本文が空でした。エラーだと思ってそのままやり直すと、同じ名前の下書きが重複してできるだけです。先に content_url の中身を確かめてください。

手順5:下書きと本文を確かめる

作成後は getcampaigndetails(campaignkey を指定)で、campaign_status が Draft になっていることを確かめます。

応答の campaign_preview には、プレビューのURLが入ります。2026年10月8日に作った下書きでは、このURLを開くと取り込まれた本文が表示されました。ただし、以前の当社の記録では下書きの段階でこの値が空のことがありました。空のときは、content_url を curl で取得し、申込リンクや日時が入っているかを見て代えています。

宛先は、同じ応答の associated_mailing_lists(宛先のリスト)、segments_info(宛先のセグメント)、total_subscribers_count(宛先件数)で確かめられます。意図した件数になっているかを、ここで見ておくと安全です。

公式ドキュメントと実機で違った点は?

いずれも当社の環境で確かめた結果です。

項目 公式ドキュメントの書き方 当社の実機での結果
パラメータの渡し方 サンプルはクエリ文字列 リクエストボディで渡さないとHTMLのエラー(HTTP 500)
セグメント指定 サンプルは list_details にセグメントIDを入れる形。別に segments もあり、両方渡すとリストが優先 list_details に入れたセグメントIDは宛先に反映されず、親リスト全体のまま。list_details を外して segments だけで渡すと、宛先のリストは空、segments_info にセグメントが入った。件数92は画面での突き合わせ前(2026年10月8日)
下書きのプレビュー - 以前は campaign_preview が下書きで空のことがあった。2026年10月8日の下書きではURLが入り、本文が表示された
修正・削除 - updatecampaign・deletecampaignは見つからない。作り直す
プリヘッダー - APIの下書きには入らない。画面で設定する

APIでセグメントに絞るにはどうするか?

公式ドキュメントのサンプルでは、list_details を {"リストキー":[セグメントID]} の形にすると、リスト内のセグメントに絞れるように読めます。当社もそう書いて、2つのセグメント向けに文面の違う下書きを作りました。ところが下書きは、セグメント情報が空で、宛先件数は親リストの2,122件のままでした。

公式には、list_details とは別に segments パラメータも定められています。説明では、list_details と segments のどちらか一方が使われ、両方を渡した場合はリストが優先されるとされています。当社のスクリプトは list_details を常に付けていたため、segments を足しても、リストが優先される条件でした。

そこで2026年10月8日に、list_details を外し、segments だけを [セグメントID] の形で渡して、テスト用の下書きを1件作りました(送信はしていません)。呼び出しは、手順4のコードの list_details の行を segments に置き換えただけです。

// 手順4の createCampaign 呼び出しで、list_details を外して segments だけで渡す版
const created = await campaigns(token, "createCampaign", {
  campaignname: "2026-10 セミナー告知(セグメント向け・下書き)",
  from_email: "YOUR_VERIFIED_SENDER@example.com",
  from_name: "株式会社サンプル",
  subject: "【10/16(金)・無料】セミナーのご案内",
  content_url: "https://YOUR_PUBLIC_HTML_URL/mail.html",
  // セグメントIDを角かっこで囲んだ文字列。当社はIDを引用符で囲まずに渡した(例:"[YOUR_SEGMENT_ID]" の YOUR_SEGMENT_ID を数字のIDに置き換える)
  segments: "[YOUR_SEGMENT_ID]",
  topicId: "YOUR_TOPIC_ID",
}, "POST");

この形は、2026年10月8日にテスト用のセグメントで作成が通ったことまでを確かめたものです。公式のパラメータ一覧では list_details が必須の印になっているため、外す形は公式の記載と食い違います。結果は次のとおりです。

  • getcampaigndetails の segments_info に指定したセグメントが入り、associated_mailing_lists(宛先のリスト)は空だった
  • 宛先件数(total_subscribers_count)は92件だった。同じ日に list_details だけで作った下書きでは、この値がリストの購読者数と一致していたため、セグメント側から数えた値と見ています(セグメントの件数をAPIで別に取る方法は見つからず、画面での突き合わせはこれから行います)

つまり当社の環境では、list_details を外して segments だけで渡すと、下書きにセグメントが設定されました。宛先件数がセグメントの人数どおりかは、画面で突き合わせるまで確定できません。公式のパラメータ一覧では list_details が必須の印になっていますが、外しても作成できました。一方、list_details に入れたセグメントIDが効かなかった原因が仕様なのか不具合なのかは、分かっていません。

当社はこれに気づかず、2026年8月に2回、宛先件数の違いに気づかないまま2本の下書きを送り、両方の文面が親リスト全体の約2,100件に届きました。同じ日に2通届いた結果、一方の配信停止は13件で、ほかの回の0〜10件より多くなりました。2026年9月15日に再現を確かめ、それ以降はAPIでの絞り込みをやめていました。

当社が2026年10月8日までに使ってきた絞り方は、次のどちらかです。segments だけで渡す方法は、画面で件数を突き合わせるまで本番では使わず、使う場合も送信前に画面で宛先件数を確かめる手順は外しません。

  • 下書きは親リストで作り、送信前にCampaignsの画面でセグメントを付け替える
  • 送りたい相手だけを入れた専用のリストを作り、そのlistkeyで下書きを作る

宛名の差し込みと、送信を実装しない理由

本文HTMLに $[LNAME|ご担当者]$様 と書くと、連絡先の姓が差し込まれ、姓が空の人には「ご担当者」が入ります。APIで作った下書きでも使えました。当社のリストでは、200件を抜き出して姓の入り方を確かめ、全件に姓が入っていました(会社名が姓の欄に入っている例が約3%ありました)。

最後に、当社が送信のAPI(sendcampaign)をあえて実装していない理由です。送信は、下書きの更新と同じ ZohoCampaigns.campaign.UPDATE スコープで動きます。つまり、スコープの設定では「下書きは作れるが送信はできない」状態を作れません。誤配信を防ぐ線は、コードに送信処理を書かないこと、送信は人が画面で確かめてから押すこと、で引いています。セグメントが効かなかった件も、送信前に画面で宛先件数を見ていれば防げました。

実装前のチェックリスト

  • Campaigns用のRefresh Tokenを、必要なスコープで発行したか
  • topicId が必要な組織かどうかを確かめたか
  • content_url が本文を返すことを curl で確かめたか。置き場に noindex を付けたか
  • POSTのパラメータをリクエストボディで渡しているか
  • セグメントに絞るとき、list_details を外して segments だけで渡しているか。宛先件数を送信前に画面で確かめる手順にしているか
  • 配信後に本文の置き場を削除する手順を決めたか

メール配信のほか、ウェビナーの申込登録もAPIで自動化しています。手順は「Zoho Meeting APIでウェビナー登録を自動化する」にまとめました。Campaignsの画面側の使い方は「Zoho campaignsでメルマガ登録フォームを作成し自社サイトに表示させる方法」で紹介しています。APIでの下書き作成など、Zohoの自動化の実装をご相談の際は「Zoho CRMカスタマイズ・設定代行」のページからどうぞ。

よくある質問

Zoho Campaigns APIはClient Credentialsで使えますか?

当社の環境では使えませんでした。CRMと同じ組織IDでsoidを組み立てても、トークンの発行で no_org が返ります(2026年10月にも同じ結果を確認)。API ConsoleのSelf Clientで認可コードを発行し、Refresh Tokenに交換して使っています。

メール本文のHTMLをAPIで直接送れますか?

createCampaignには本文そのものを渡すパラメータがなく、content_urlに公開URLを指定して、Zoho側に取り込ませます。当社はCloudflareに一時的な置き場を作り、検索エンジンに載らない設定をしてから渡し、配信後に消しています。

APIで作った下書きをAPIで修正・削除できますか?

当社が試した範囲では、updatecampaign・deletecampaignという名前のAPIは見つからず、エラーになりました。直すときは作り直して、古い下書きを画面で削除しています。

セグメントに絞った下書きをAPIで作れますか?

list_details を渡さず、segments パラメータだけで [セグメントID] の形で渡すと、下書きにセグメントが設定されました(2026年10月8日に当社の環境で確認)。宛先のリストは空になりましたが、宛先件数がセグメントの人数と一致するかは、画面での突き合わせがまだです。一方、公式のサンプルどおり list_details にセグメントIDを入れた下書きは、セグメント情報が空で、宛先件数は親リスト全体のままでした。公式では両方渡すとリストが優先されるため、セグメントに絞るときは list_details を外します。どちらの方法でも、送信前に画面で宛先件数を確かめてください。

宛名の差し込みはAPIの下書きでも使えますか?

使えます。本文HTMLに $[LNAME|ご担当者]$様 と書くと、姓が入っている連絡先には姓が、空の連絡先には「ご担当者」が入ります。送信前にテスト送信で表示を確かめてください。