Skip to main content
友田 陽大
Resend & transactional email
Resend
メール配信
TypeScript
Next.js
到達率
信頼性
セキュリティ

Resend batch, scheduled sends, and Broadcasts: Segments/Topics, one-click unsubscribe, and Japan's anti-spam law

Everything in Resend beyond sending one email at a time: batch strict vs permissive, scheduledAt and cancel, Segments/Topics, Broadcasts, and RFC 8058 one-click unsubscribe.

Published
Reading time
26 min read
Author
友田 陽大
Share

"Send an announcement to everyone who signed up." The first instinct on receiving that ticket is to wrap emails.send() in a for loop. It works — until a few hundred messages in, when you hit the rate limit, keep sending with no unsubscribe path, watch the complaint rate climb, and eventually damage your domain's reputation.

In Resend, everything other than "one email at a time" is four distinct things: batch, scheduled, Broadcasts, and Automations. They differ in how recipients are chosen, whether you are obliged to implement unsubscribe, and whether idempotency keys work at all. This article maps those four faithfully to the official documentation, then carries the thread through subscription management (Segments, Topics, unsubscribe) all the way to Japan's anti-spam law. The code comes from something this site actually runs in production: a seven-part email course with no database and no cron. The wider picture lives in the Resend production guide.


"Sending in bulk" is four different things

Confusing these four does not cost you a refactor. It costs you your sender reputation, which is far harder to get back.

Way to sendWhat it's forHow recipients are chosenUnsubscribeIdempotency key
Single emails.sendOne event, one email (password reset, order confirmation)Code sets to (max 50)Generally not required (exceptions below)Supported
Batch batch.sendMany transactional emails with different content at once (max 100)Code sets it per entrySame as singleSupported (one key for the whole batch)
BroadcastsNewsletters, product updatesYou pick a Segment; a Topic excludes opt-outsRequired. Resend hosts the preference pageNot supported
AutomationsEvent-driven dripsThe contact the trigger points atYou must put the merge tag in the template yourselfNot supported

The idempotency column has a source. The docs state that "idempotency keys are currently supported on the POST /emails and the POST /emails/batch endpoints" — Broadcasts and Automations are simply absent from that sentence. Retry design is covered in the idempotency and retry article.

Resend's own taxonomy is worth internalising too. It defines a transactional email as "a message triggered by a user action or required for legal compliance," typically "1-to-1 messages sent in response to a specific event," and marketing email as promotional, informative or general communication that is "regulated by laws like CAN-SPAM (US) and CASL (Canada), and recipients must have the option to unsubscribe." It even addresses the grey zone directly: marketing emails can be 1-to-1 (abandoned cart, drip campaigns), and you may send those through the transactional APIs and Automations "as long as proper consent and compliance regulations are followed." In other words, the regulation attaches to what the content is, not to which endpoint you called.


Batch sending: up to 100 emails in one request

resend.batch.send() hits POST /emails/batch: up to 100 emails per request, with the same per-entry parameters as POST /emails — except attachments are not supported. The docs are explicit: "Emails with attachments cannot be sent using our batching endpoint," and inline images count as attachments, so they are out too.

There is a trap here. resend@6.4.1 still types the payload as CreateBatchOptions = CreateEmailOptions[], so the compiler does not reject attachments. It compiles, and it fails at runtime. This constraint lives in review and tests, not in the type system.

import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);

// Pick a key that represents the WHOLE batch (the documented convention is <event-type>/<entity-id>)
const { data, error } = await resend.batch.send(
  [
    { from: "Acme <onboarding@resend.dev>", to: ["foo@example.com"], subject: "hello", html: "<h1>it works!</h1>" },
    { from: "Acme <onboarding@resend.dev>", to: ["bar@example.com"], subject: "world", html: "<p>it works!</p>" },
  ],
  { idempotencyKey: "team-quota/123456789" },
);

// The SDK does not throw. Branching on the returned error is the only correct shape.
if (error) return console.error("[batch] failed", { name: error.name, statusCode: error.statusCode });

// ⚠ Note the doubled data. data.data[i] corresponds to entry i of the payload (0-based).
data.data.forEach((sent, index) => console.info("[batch] queued", { index, id: sent.id }));

The doubled data.data is the type itself (CreateBatchSuccessResponse is { data: { id: string }[] }). Writing data[i].id and staring at undefined is a common first-hour bug, so get it out of the way early.

The difference between strict and permissive shows up in the type

The second argument takes batchValidation?: 'strict' | 'permissive', and the default is strict.

ModeWhen one entry is invalidReturn value
strict (default)The entire request fails and nothing is sentdata.data only
permissiveValid entries are sent; failures come back by indexdata.data plus a typed data.errors

Here is the type, abridged from dist/index.d.ts in resend@6.4.1.

type CreateBatchSuccessResponse<Options extends CreateBatchRequestOptions> = {
  data: { id: string }[];
} & (Options["batchValidation"] extends "permissive"
  ? { errors: { index: number; message: string }[] }  // present only in permissive mode
  : Record<string, never>);

Because the branch is a conditional type, pass the options as an inline object literal. If you hoist them into a variable annotated as CreateBatchRequestOptions, the property widens to 'strict' | 'permissive', the condition evaluates to false, and errors disappears from the type.

// Inline literal pins Options["batchValidation"] to "permissive", so data.errors type-checks.
// Going through a variable widens the type and the errors field vanishes.
const { data, error } = await resend.batch.send(payload, { batchValidation: "permissive" });
if (error) return;

// Pick up partial failures by index, match them against the original payload, requeue.
for (const f of data.errors) console.warn("[batch] item failed", { index: f.index, message: f.message });

An honest caveat. permissive is implemented in the SDK and the CLI (it travels as the x-batch-validation HTTP header), but it appears neither in the API reference nor in the OpenAPI spec, and the actual HTTP shape of errors[] is undocumented. Verify the real response in staging before you depend on it.

The remaining constraints: 100 emails per request, to capped at 50 per email, each email processed independently with an initial status of queued, and each email in a batch can be scheduled independently. For marketing, the docs point you at Broadcasts.


Scheduled sending: scheduledAt / update / cancel

scheduledAt takes an ISO 8601 string. REST and the official samples also accept natural language ("in 1 hour", "tomorrow at 9am", "Friday at 3pm ET", "in 1 min", "in 5 min"), but no formal grammar for the accepted phrasings is published — do not assume anything beyond those examples works. On the server, building ISO 8601 with toISOString() is the safer habit. Three limits apply: 30 days ahead maximum, SMTP cannot schedule, and a cancelled email cannot be rescheduled.

// Schedule
const { data: scheduled, error } = await resend.emails.send({
  from: "Acme <onboarding@resend.dev>",
  to: ["user@example.com"],
  subject: "Reminder",
  html: "<p>Your event is tomorrow</p>",
  scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
});
if (error || !scheduled) return;

// Reschedule (scheduled_at is the ONLY updatable field — not recipients, not body)
await resend.emails.update({
  id: scheduled.id,
  scheduledAt: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(),
});

// Cancel (POST, not DELETE)
await resend.emails.cancel(scheduled.id);

The reference and the guide disagree. The PATCH /emails/:id reference describes scheduled_at as "ISO 8601 format" and never mentions natural language, while the reschedule section of the Schedule email guide uses 'in 1 min' in the SDK, cURL and CLI samples alike. Writing ISO 8601 on update satisfies both readings.

The trap that bites in production: deleting an API key kills every scheduled email

The docs list exactly two reasons a scheduled email never goes out, and both are operational accidents. The API key is no longer active — deleted, expired or suspended — and the email cannot be sent. Or the account is under review and sending is suspended. Which means key rotation takes your scheduled queue with it. Swap the key while thirty days of messages are stacked up and your subscribers receive nothing. Keep the old key alive until everything scheduled with it has gone out.

A worked example: a seven-part course with no DB and no cron

The seven-day course you can sign up for from /resources on this site is implemented without a database and without a scheduler — purely on scheduled sends (app/api/email-course/route.ts). Two ideas carry it.

1. Create them in reverse. Days 7 down to 2 are scheduled, and day 1 is created last and sent immediately. The reason is that each email's unsubscribe token has to embed the email IDs of all the later days. By the time day 3 is created, the IDs for days 4 through 7 already exist, so day 3's unsubscribe link can carry "cancel 4–7" inside itself. That single property is why no database is needed.

// lib/email-course-render.ts (excerpt)

/** Creation order = 7 → 2 (scheduled), then 1 (immediate). Descending, so later IDs are known first. */
export function orderDaysForCreation(days: readonly EmailCourseDay[]): EmailCourseDay[] {
  return [...days].sort((a, b) => b.day - a.day);
}

/** Day N lands at now + (N-1)×24h. Day 1 returns undefined, i.e. send immediately. */
export function scheduledAtFor(day: EmailCourseDay, nowMs: number): string | undefined {
  return day.day > 1 ? new Date(nowMs + (day.day - 1) * COURSE_DAY_MS).toISOString() : undefined;
}

Day 7 is only now + 6 days, so this sits comfortably inside the 30-day ceiling. A design that breaks that ceiling (a course longer than 30 days) needs a real scheduler instead.

2. If a create fails midway, cancel what you already made — best effort. When the fourth of seven API calls fails, leaving three scheduled emails in place means the subscriber gets a course that starts in the middle. With no transactions available, you write the compensating action yourself.

// app/api/email-course/route.ts (excerpt, simplified)
const createdIds: string[] = [];

for (const day of orderDaysForCreation(EMAIL_COURSE.days)) {
  // Seal the already-created "later day" IDs into the unsubscribe token
  const token = buildUnsubscribeToken({ email: data.email, cancelIds: [...createdIds], expiresAtEpochSec }, secret);
  const unsubUrl = `${SITE.url}/api/email-course/unsubscribe?token=${encodeURIComponent(token)}`;
  const { html, text } = renderDay(day, unsubUrl);
  const scheduledAt = scheduledAtFor(day, now);

  const { data: created, error } = await client.emails.send({
    from,
    to: [data.email],
    subject: day.subject,
    html,
    text,
    ...(scheduledAt ? { scheduledAt } : {}),
    // RFC 8058: one-click only exists when BOTH headers are present
    headers: {
      "List-Unsubscribe": `<${unsubUrl}>`,
      "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
    },
  });

  if (error || !created) {
    // Never leave a partially scheduled sequence behind; log whatever cannot be cancelled
    await cancelBestEffort(client, createdIds);
    return NextResponse.json({ error: "登録処理に失敗しました" }, { status: 502 });
  }
  createdIds.push(created.id);
}

cancelBestEffort fires the cancellations through Promise.allSettled and records the failure count with console.warn. A scheduled email you failed to cancel will still be delivered one day, so the point is to make it discoverable rather than swallow it. This is my own operational design, not an official recommendation.


Segments and Contacts: the data model changed

This is the area where 2024–2025 knowledge is wrong from top to bottom.

Old understanding (discard)The correct 2026 understanding (official)
A Contact belongs to an AudienceContacts are global. A Contact can be in zero, one, or many Segments
The same address is a separate object per AudienceOne address is one Contact across the team — and counts once against quota
Contact endpoints require audience_idNo audience_id. You call POST /contacts directly
Unsubscribe happens per AudienceUnsubscribing shows a preference page: per-Topic, or everything
Use the Audiences APIAudiences are deprecated. Segments is the current model

The migration guide puts it plainly: what used to be Audiences are now Segments, Contacts became independent of them, "a Contact can be in zero, one or multiple Segments and still count as one when calculating your quota usage." The Audiences API pages carry a banner reading "These endpoints still work, but will be removed in the future," and the SDK marks resend.audiences as @deprecated (it resolves to the very same Segments class). The three concepts, in the docs' own words: a Contact is a global entity tied to an email address, a Segment is an internal organisation tool for your team, and a Topic is a user-facing tool for managing email preferences.

// segments.create returns { data, error } too — never touch data without checking error
const { data: segment, error: segmentError } = await resend.segments.create({
  name: "Registered Users",
});
if (segmentError) return;

// Create the Contact. No audienceId needed under the global Contacts model.
const { data: contact, error } = await resend.contacts.create({
  email: "steve.wozniak@example.com",
  firstName: "Steve",
  lastName: "Wozniak",
});
if (error) return;

// Add it to the Segment — by contactId or by email address
await resend.contacts.segments.add({ contactId: contact.id, segmentId: segment.id });

SDK vs docs. The POST /contacts documentation says the body accepts a segments array and a topics array, but CreateContactOptions in resend@6.4.1 has neither (only audienceId, email, unsubscribed, firstName, lastName, properties). Through the SDK it is a two-step dance: create, then contacts.segments.add(). Always check the types of the version you actually installed.

Custom attributes (Contact Properties) are stricter than they look. A key must be alphanumeric plus underscore, max 50 characters, and it is case-sensitive. Values are strings or numbers, and fallback_value must match the property's type. Crucially, if the property key does not already exist, the create or update call fails and returns an error — the same happens on a type mismatch. Run resend.contactProperties.create({ key: "company_name", type: "string", fallbackValue: "Acme Corp" }) first.

List hygiene: stop sending to dead addresses

One line in the knowledge base is routinely missed.

Resend will automatically suppress further deliveries to that email address but will not automatically unsubscribe it for you.

The suppression list applies team-wide across every domain, but the Contact's unsubscribed flag stays where it was. When a bounce or complaint arrives, you have to update the contact — which is exactly what webhooks are for (see the webhooks, bounces and complaints article).

// On email.bounced / email.complained, flip the flag yourself
await resend.contacts.update({ email: bouncedAddress, unsubscribed: true });

The other hygiene practices Resend names translate straight into a checklist: a CAPTCHA on the signup form, double opt-in, a third-party address verification service, and the engagement filter — "limit non-transactional email sends to recipients who have opened or clicked an email in the past 6 months."


Topics: a subscription category is a contract with the recipient

Topics are the mirror image of Segments, and the official comparison table says it best.

AspectTopicsSegments
Who controls itYour recipientsYou (the sender)
VisibilityShown on the unsubscribe pageInternal only; recipients never see them
PurposeLet users manage their preferencesOrganize contacts for targeted sending
Example"Newsletter", "Product Updates""Enterprise customers", "Free trial users"

Or, verbatim: "Topics don't define who receives a message. They define who asked not to receive that message." Segments are for targeting; Topics are for protecting preferences.

const { data: topic } = await resend.topics.create({
  name: "Weekly Newsletter",        // max 50 characters
  description: "A weekly digest",   // max 200 characters
  defaultSubscription: "opt_in",    // ⚠ cannot be changed later
});

defaultSubscription is immutable after creation. opt_in means everyone receives it unless they explicitly unsubscribe — and it applies retroactively to all existing contacts. opt_out means nobody receives it unless they explicitly subscribe. visibility defaults to private; making it public lets every contact see the Topic on the unsubscribe page. Only name, description and visibility can be edited afterwards. Note, though, that CreateTopicOptions and UpdateTopicOptions in resend@6.4.1 have no visibility field at all — you cannot set it through the SDK, so it is the dashboard or the generic resend.post() / resend.patch() helpers.

You can pass topicId on a transactional emails.send as well. Three rules decide the outcome: a contact who is opted in receives it; a contact who is opted out does not, and the email is marked as failed; a recipient who is not a contact receives it only if the Topic's default is opt_in. Each of to, cc and bcc is evaluated separately. And the global Subscribed flag wins: if it is false, the contact receives nothing even when opted in to that Topic.

For Broadcasts, the presence of a Topic changes what unsubscribing means. The official warning is the design rule:

If you send a Broadcast without a Topic and someone unsubscribes, they'll be unsubscribed from all your emails.

Hence the guidance to always label a Broadcast with a Topic — alongside two counterweights: aim for 3–5 distinct content types ("recipients get overwhelmed when faced with a long list of checkboxes"), and if you only ever send one kind of marketing email, "Topics add complexity without much benefit." Count your actual message types before you create anything.

// Apply the recipient's choices (call this from your own preference page)
await resend.contacts.topics.update({
  id: contactId,
  topics: [
    { id: newsletterTopicId, subscription: "opt_out" },
    { id: productUpdatesTopicId, subscription: "opt_in" },
  ],
});

A documentation inconsistency. The parameter spec and the Node sample for PATCH /contacts/{id}/topics send { topics: [...] }, while the cURL sample on the same page sends a bare array as the whole body. The SDK type is { id, topics }, so through the SDK there is no ambiguity. If you call it with cURL, confirm against the live response.


Broadcasts: create → send, drafts, scheduling

Resend's pitch is that it handles "queuing, throttling, and scheduling for you so that you don't have to roll your own infrastructure." You must send from a verified domain (see the domain authentication and deliverability article).

// 1. Create the draft (segmentId, from and subject are required)
const { data: broadcast, error } = await resend.broadcasts.create({
  segmentId: "78261eea-8f8b-4381-83c6-79fa7120f1cf",
  from: "Acme <newsletter@example.com>",
  subject: "This month's update",
  topicId: newsletterTopicId,           // without it, unsubscribing means unsubscribing from everything
  previewText: "Three new features shipped",
  html: "Hi {{{contact.first_name|there}}}, you can unsubscribe here: {{{RESEND_UNSUBSCRIBE_URL}}}",
});
if (error) return;

// 2. Send or schedule it (scheduledAt lives on send(), see the caveat below)
await resend.broadcasts.send(broadcast.id, { scheduledAt: "in 1 hour" });

Merge tags use triple braces. {{{RESEND_UNSUBSCRIBE_URL}}} expands to a link unique per recipient and per Broadcast, and the pipe in {{{contact.first_name|there}}} separates the fallback value.

An SDK version difference that matters. The official Node sample calls broadcasts.create({ ..., send: true, scheduledAt: 'in 1 hour' })create and schedule in one call. But CreateBroadcastOptions in resend@6.4.1 has neither send nor scheduledAt (scheduledAt sits on the broadcasts.send() options instead). On that version it is the two-call create → send flow shown above. The docs' sample presumably targets a newer SDK, so check your own types before writing either form. Note too that POST /broadcasts/{id}/send works only for Broadcasts created via the API — anything built in the dashboard editor cannot be sent through it.

StatusMeaningWhat you can do
draftA draftEdit, delete, schedule
scheduledScheduled to sendCancel the schedule (returns to draft), edit, delete
queuedQueued for deliveryCancel — stops the remaining deliveries only
sentSentRename, nothing else

The rules, verbatim in spirit: you can edit the content and properties of any draft or scheduled Broadcast, but "once a Broadcast has been sent, only its name can be updated," and only drafts (including scheduled ones) can be deleted. Cancelling mid-send "stops delivery to recipients who haven't received the email yet. Emails that have already been sent cannot be recalled." One detail to watch: Broadcast.status in resend@6.4.1 is typed 'draft' | 'sent' | 'queued' and does not include the scheduled value the dashboard docs describe — check a live response before you narrow a type on that string.

The dashboard reports emails delivered, unsubscribed, click rate and open rate, with Resend itself noting that "open rates can be inaccurate" because of how inbox providers handle incoming mail — so do not build your KPIs on opens. The richer GET /broadcasts/{id}/metrics and the recipients endpoint are in private beta, with the response shape explicitly allowed to change before GA. Their semantics contain a nice detail: while a broadcast is still sending, percentages divide by the number sent so far (delivered plus bounced) and switch to the full total once it finishes, while suppressions are decided before sending and therefore always divide by the full total. That is why the dashboard numbers appear to move mid-flight.


Implementing unsubscribe correctly

Google's rule is that senders of more than 5,000 messages per day "must support one-click unsubscribe" for marketing and subscribed messages. Resend echoes it: since February 2024, bulk messages must carry a URL in the list-unsubscribe header plus List-Unsubscribe-Post: List-Unsubscribe=One-Click, and must accept a POST at that same URL. You need both headers; one alone does nothing.

List-Unsubscribe: <https://example.com/unsubscribe/opaquepart>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

Five normative requirements from RFC 8058 shape the implementation.

  • List-Unsubscribe MUST contain one HTTPS URI (other schemes such as mailto: may accompany it).
  • The message MUST carry a valid DKIM signature covering both headers (listed in the h= tag). Without it, receivers SHOULD NOT offer one-click at all.
  • The URI MUST contain enough information to identify the recipient and the list — there are no extra POST arguments, so everything is encoded in the URI.
  • The URI SHOULD include an opaque identifier or another hard-to-forge component, and the server SHOULD verify it.
  • The POST MUST NOT include cookies, HTTP authorization, or any other context information, and the sender MUST NOT return an HTTPS redirect to it.

Resend adds its own operational requirement: on a POST, return a blank page with 200 (OK) or 202 (Accepted); show the normal unsubscribe page on GET; and stop sending within 48 hours of the request.

A hole in the official sample. Resend's code sample for adding unsubscribe to transactional email sets only List-Unsubscribe — even though the prose on the same page says one-click needs both. If you copy it, add List-Unsubscribe-Post yourself. Separately, the claim that Broadcasts emit both headers automatically appears on Resend's blog but not in the documentation — treat it as a blog claim, not a documented guarantee.

For Broadcasts and Automations, putting {{{RESEND_UNSUBSCRIBE_URL}}} in the body hands the whole flow to Resend: with no Topics configured the contact is unsubscribed from everything, and with Topics they get a preference page listing every public Topic. Automations behave differently though — "Resend does not automatically add an unsubscribe link to emails sent from a send_email step," and the tag "must be added to the Template itself, not the step's config" (template design is covered in the templates and variables article).

Rolling your own signed token

When you manage the list yourself, you build the endpoint. My answer to the RFC's "hard-to-forge component" was a self-contained token signed with HMAC-SHA256.

// lib/email-course-token.ts (excerpt)
export interface UnsubscribeTokenPayload {
  readonly email: string;                // used to mark the Contact unsubscribed
  readonly cancelIds: readonly string[]; // IDs of the later days not yet sent
  readonly expiresAtEpochSec: number;    // expires well after the final send
}

// Format: base64url(JSON) + "." + base64url(HMAC-SHA256(body, secret))
export function buildUnsubscribeToken(payload: UnsubscribeTokenPayload, secret: string): string {
  const body = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
  return `${body}.${sign(body, secret)}`;
}

Verification compares with timingSafeEqual and is a total function: tampered, expired, or wrong-secret input all collapse to null. Then GET and POST diverge.

// GET — a human clicked the link in the email: do the work, then 303 to the thank-you page
export async function GET(request: NextRequest) {
  const payload = verifyFromRequest(request, secret);
  if (!payload) return NextResponse.json(INVALID_TOKEN_RESPONSE, { status: 400 });
  await processUnsubscribe(payload);
  return NextResponse.redirect(new URL("/unsubscribed", request.url), 303);
}

// POST — RFC 8058 one-click from a mail client: 200 JSON, no redirect
export async function POST(request: NextRequest) {
  const payload = verifyFromRequest(request, secret);
  if (!payload) return NextResponse.json(INVALID_TOKEN_RESPONSE, { status: 400 });
  await processUnsubscribe(payload);
  return NextResponse.json({ ok: true }, { status: 200 });
}

Not redirecting the POST is not a stylistic choice; the RFC forbids it, because redirected POSTs have historically been unreliable and many browsers turn them into GETs. The GET is for humans, so a 303 to a "you're unsubscribed" page is fine. The work itself is small: cancel every still-scheduled later day and mark the contact unsubscribed.

async function processUnsubscribe(payload: UnsubscribeTokenPayload): Promise<void> {
  // Days already sent can no longer be cancelled — individual failures are expected, so allSettled
  await Promise.allSettled(payload.cancelIds.map((id) => client.emails.cancel(id)));
  await client.contacts.update({ email: payload.email, unsubscribed: true });
}

Remember that the unsubscribe endpoint is a public URL with no authentication. The signature check is the only thing standing there, so treat the secret and the token validation as security-critical. Rate limiting for public endpoints is covered in the serverless rate-limiting article.


Japanese law: the Act on Regulation of Transmission of Specified Electronic Mail

Satisfying the RFC and the mailbox providers still leaves a separate set of duties for mail sent in Japan, under the Act on Regulation of Transmission of Specified Electronic Mail (Act No. 26 of 2002). What follows is based on the current text published on e-Gov.

Who is covered (Article 2). "Specified electronic mail" means email sent, as a means of advertising or publicising one's own or another's business, by a for-profit organisation or an individual in the course of business (limited to transmission from or to telecommunications facilities located in Japan). A freelancer promoting their own services is squarely inside it. Per the Ministry of Internal Affairs and Communications, services that exchange messages by phone number (SMS and similar) are covered too.

Opt-in (Article 3). A sender may not send specified electronic mail to anyone other than:

  1. a person who has notified the sender in advance that they request or consent to receiving it;
  2. a person who has notified the sender of their address in the manner prescribed by ministerial ordinance;
  3. a person with whom the sender has a business relationship; or
  4. an organisation, or an individual carrying on a business, that has published its email address in the prescribed manner.

Paragraph 2 imposes a record-keeping duty for consent. The retention period, from Article 4(2) of the enforcement regulation, is one month from the date of the last send (one year if you have received an order to take measures). But mail-order businesses are also caught by the opt-in rule of the Act on Specified Commercial Transactions, which requires keeping the record of consent for three years from the date the last email advertisement was sent. Designing for the longer period is the practical safe choice.

Paragraph 3 says that once you receive an opt-out notice, you must not send against that expressed intent. Article 6 of the enforcement regulation carves out exceptions, notably where an advertisement appears incidentally within an email notifying a person of the application for, contents of, or performance of a contract. That is the legal basis for a single promotional line at the bottom of an order confirmation — and the mirror image is that you are outside the exception the moment advertising becomes the main purpose.

Disclosure (Article 4, plus Articles 7 and 9 of the regulation). The following must render correctly on the recipient's screen. The five-item plain-language summary published by the anti-spam consultation centre commissioned by the ministry works as an implementation checklist verbatim.

Item to displayWhere it goes (enforcement regulation)
The sender's nameAnywhere the recipient can readily notice it
An email address or URL for opt-out noticesAnywhere the recipient can readily notice it
A statement that the recipient may opt outImmediately before or after that address
The sender's postal addressAnywhere
A phone number, email address or URL for complaints and enquiriesAnywhere

The footer of this site's email course is that table turned into code.

// lib/email-course-render.ts (excerpt)
const text = [
  "----",
  intro,
  `送信者: ${SITE.author}`,                                       // name
  `お問い合わせ: ${contactUrl}`,                                   // complaints and enquiries
  `送信者情報(氏名・連絡先メールアドレス・住所): ${tokushohoUrl}`,  // postal address disclosed on the legal page
  `プライバシーポリシー: ${privacyUrl}`,
  `配信停止はこちら(ワンクリックで解除できます): ${unsubUrl}`,       // opt-out statement adjacent to the address
].join("\n");

The opt-out sentence and the URL sit on the same line, adjacent to each other, precisely because Article 7 requires the statement to appear immediately before or after the notification address. Article 7(2) also requires the items to be encoded in the same character encoding as the message body — you cannot discharge the duty with an image or a link alone.

Penalties (Articles 34 and 37). Sending with falsified sender information, or failing to comply with an order to take measures, carries imprisonment for up to one year or a fine of up to one million yen. For a corporation, the offender is punished and the company faces a fine of up to thirty million yen.

Update your knowledge here. Following the criminal-law reform in force from 1 June 2025, the current text uses 拘禁刑 (confinement) rather than 懲役 (imprisonment with labour). Pre-2024 explainers — and LLM output trained on them — still say "1年以下の懲役," which the current statute no longer says.

The dividing line is not which API you called but whether advertising is the main purpose of the message. Password resets, order confirmations and shipping notices are notices about the performance of a contract, so they are not specified electronic mail, and an incidental ad falls under the Article 6 exception. Newsletters and campaign announcements attract the opt-in rule and the full disclosure duty. The arguments happen in the middle — an educational sequence the recipient signed up for, for instance. My rule is: when in doubt, treat it as advertising and implement the footer completely. Compliance costs a few lines in a footer; non-compliance costs an administrative order and a fine.


Pre-production checklist

  • You can name which of the four (single / batch / Broadcast / Automation) you are actually building
  • Batch respects 100 emails, no attachments, and you map results by data.data[i]
  • If you use permissive, the options are an inline literal and you verified the live response shape
  • Scheduled sends fit within 30 days and do not collide with your API-key rotation plan
  • A compensating cancel exists for a bulk schedule that fails partway through
  • You pass segmentId, not audienceId (or existing code has a migration plan)
  • Every Broadcast carries a Topic (without one, unsubscribing means unsubscribing from everything)
  • You chose defaultSubscription knowing it can never be changed
  • Both List-Unsubscribe and List-Unsubscribe-Post are sent
  • Unsubscribe GET renders a page; POST returns 200 or 202 with no redirect
  • The unsubscribe URL carries a hard-to-forge token that the server verifies
  • Bounce and complaint webhooks mark the contact unsubscribed yourself
  • The footer carries all five statutory items, with the opt-out statement adjacent to the address
  • Consent records are retained for three years if the Specified Commercial Transactions Act applies

Wrapping up

As long as "sending in bulk" looks like one feature, the implementation will break somewhere. Five things to take away.

  • The four ways to send are different products. Recipient selection, unsubscribe obligations and idempotency support all differ.
  • Batch defaults to strict. permissive exists for partial failure, but verify it — the official spec is silent on it.
  • Scheduling means 30 days, a one-way cancel, and a fate tied to your API key.
  • Segments, not Audiences — and never conflate Segments (targeting) with Topics (recipient preferences).
  • Unsubscribe is two headers plus a signed URL plus the GET/POST split, with five statutory disclosure items on top in Japan.

Start by sorting the mail your product sends today into those four buckets. The moment you do, the messages with an incomplete footer — and the ones you should not be sending at all — become visible. If you are reconsidering the service itself, the email service comparison is the companion piece.

This article is based on the Resend official documentation (Batch / Schedule / Segments / Topics / Broadcasts / Unsubscribe, as of August 2026), the type definitions of the installed resend@6.4.1, RFC 8058, and the current text of Japan's Act on Regulation of Transmission of Specified Electronic Mail and its enforcement regulation on e-Gov, with operational judgement added. Specifications, limits and plans change, so confirm the current values on the official pages before adopting anything in production. If you need certainty about how the law applies to your case, consult a professional.

Frequently asked questions

Should I use batch sending or Broadcasts?
Use batch (up to 100) when each recipient gets different transactional content, and Broadcasts when the same marketing message goes to a list. The batch documentation says so itself: "For marketing campaigns, use our no-code editor, Broadcasts, instead." Broadcasts hand queuing, throttling, scheduling and the whole unsubscribe flow to Resend, so you don't have to manage a list yourself.
How far ahead can I schedule, and can I cancel?
The documented limit is 30 days ahead. Cancelling is POST /emails/:id/cancel (not DELETE), but the docs carry a warning: "Once an email is canceled, it cannot be rescheduled." If you only want to move the time, PATCH /emails/:id and update scheduled_at. That endpoint accepts scheduled_at and nothing else — you cannot change recipients or body.
Am I no longer allowed to use Audiences?
Use Segments for anything new. The Audiences endpoints carry an official banner — "Audiences are deprecated in favor of Segments. These endpoints still work, but will be removed in the future" — and in resend@6.4.1 the resend.audiences property is marked @deprecated (it is literally the same class as Segments). No removal date is published, so you do not have to rip out working code today. The part that matters is moving unsubscribe management from Audiences to Topics.
Resend says transactional email is "generally exempt" but recommends telling recipients how to opt out when the content leans toward nurturing the relationship. In Japan, an ad that appears incidentally inside a notice about a contract's conclusion, contents or performance falls under the exception in Article 6 of the enforcement regulation — which also means the disclosure duty kicks in the moment advertising becomes the main purpose. When in doubt, include it.
What exactly must a marketing email display under Japanese law?
Article 4 and the enforcement regulation require: the sender's name, an email address or URL for opt-out notices, a statement that the recipient may opt out placed immediately before or after that address, the sender's postal address, and a phone number, email address or URL for complaints and enquiries. Articles 7 and 9 of the regulation govern placement — the items must sit where the recipient can readily notice them.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

Transactional email infrastructure on Resend — design, implementation, and production operations

"We sent it" is not "it arrived." I close that gap in code and DNS rather than in operational vigilance: sender-domain authentication (SPF/DKIM/DMARC), a typed send path validated with Zod, idempotency keys and backoff for resilience, bounce and complaint handling over webhooks, RFC 8058 one-click unsubscribe, and structured logs that never carry PII. This site's own contact form, gated resources, email course and post-purchase mail all run on Resend in production — including the outage where misplaced authentication records took sending down entirely.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Also worth reading