"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 send | What it's for | How recipients are chosen | Unsubscribe | Idempotency key |
|---|---|---|---|---|
Single emails.send | One event, one email (password reset, order confirmation) | Code sets to (max 50) | Generally not required (exceptions below) | Supported |
Batch batch.send | Many transactional emails with different content at once (max 100) | Code sets it per entry | Same as single | Supported (one key for the whole batch) |
| Broadcasts | Newsletters, product updates | You pick a Segment; a Topic excludes opt-outs | Required. Resend hosts the preference page | Not supported |
| Automations | Event-driven drips | The contact the trigger points at | You must put the merge tag in the template yourself | Not 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.
| Mode | When one entry is invalid | Return value |
|---|---|---|
strict (default) | The entire request fails and nothing is sent | data.data only |
permissive | Valid entries are sent; failures come back by index | data.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.
permissiveis implemented in the SDK and the CLI (it travels as thex-batch-validationHTTP header), but it appears neither in the API reference nor in the OpenAPI spec, and the actual HTTP shape oferrors[]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/:idreference describesscheduled_atas "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 Audience | Contacts are global. A Contact can be in zero, one, or many Segments |
| The same address is a separate object per Audience | One address is one Contact across the team — and counts once against quota |
Contact endpoints require audience_id | No audience_id. You call POST /contacts directly |
| Unsubscribe happens per Audience | Unsubscribing shows a preference page: per-Topic, or everything |
| Use the Audiences API | Audiences 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 /contactsdocumentation says the body accepts asegmentsarray and atopicsarray, butCreateContactOptionsinresend@6.4.1has neither (onlyaudienceId,unsubscribed,firstName,lastName,properties). Through the SDK it is a two-step dance: create, thencontacts.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.
| Aspect | Topics | Segments |
|---|---|---|
| Who controls it | Your recipients | You (the sender) |
| Visibility | Shown on the unsubscribe page | Internal only; recipients never see them |
| Purpose | Let users manage their preferences | Organize 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}/topicssend{ 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. ButCreateBroadcastOptionsinresend@6.4.1has neithersendnorscheduledAt(scheduledAtsits on thebroadcasts.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 thatPOST /broadcasts/{id}/sendworks only for Broadcasts created via the API — anything built in the dashboard editor cannot be sent through it.
| Status | Meaning | What you can do |
|---|---|---|
draft | A draft | Edit, delete, schedule |
scheduled | Scheduled to send | Cancel the schedule (returns to draft), edit, delete |
queued | Queued for delivery | Cancel — stops the remaining deliveries only |
sent | Sent | Rename, 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-UnsubscribeMUST contain one HTTPS URI (other schemes such asmailto: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, addList-Unsubscribe-Postyourself. 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:
- a person who has notified the sender in advance that they request or consent to receiving it;
- a person who has notified the sender of their address in the manner prescribed by ministerial ordinance;
- a person with whom the sender has a business relationship; or
- 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 display | Where it goes (enforcement regulation) |
|---|---|
| The sender's name | Anywhere the recipient can readily notice it |
| An email address or URL for opt-out notices | Anywhere the recipient can readily notice it |
| A statement that the recipient may opt out | Immediately before or after that address |
| The sender's postal address | Anywhere |
| A phone number, email address or URL for complaints and enquiries | Anywhere |
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, notaudienceId(or existing code has a migration plan) - Every Broadcast carries a Topic (without one, unsubscribing means unsubscribing from everything)
- You chose
defaultSubscriptionknowing it can never be changed - Both
List-UnsubscribeandList-Unsubscribe-Postare 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.
permissiveexists 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.