Your broken SMTP password can keep generating start fees all night.

You rotate a credential. Notifications stop. The scheduler keeps trying, while an empty dataset makes the spending dashboard look reassuring.

A failed email run can incur a start charge without producing a result record. Validation and sending failures leave the reason in the run's status message. The dataset stays empty because a confirmation is written only after the SMTP server accepts the message.

Your budget needs to count authorized attempts before execution. Counting confirmation rows afterward misses the failure you most need to contain.

What does an empty dataset actually save?

Starting execution and writing a result are separate charging events.

The start event is charged when execution begins. Its quantity depends on allocated memory: one event per GB, with a minimum of one event. The result event applies to a record in the default dataset.

Platform usage is additional, including usage consumed by a failed run.

For this worked example, the Actor sends a single email per run. Multiple recipients still belong to that message.

What you change Budget consequence
Scheduled starts, retries, manual starts Each execution creates another start-charge opportunity
Allocated memory Changes the number of start events
to, cc, or bcc recipient lists Does not create a separate confirmation per recipient
Body or attachment content Can affect SMTP acceptance, without creating a separate published content event

Put every launch behind the same allowance. First, remove failures you can catch without starting anything.

Reject broken inputs before paying to discover them

The schema has 13 properties, with 5 required fields. The body requirement crosses fields, so checking required keys alone misses it.

This local policy also rejects blank required strings. Attachment entries receive a string-value check here; attachment contents and SMTP acceptance need separate checks.

Example input and validation for a scheduled notification:

import { z } from 'zod'; import { ApifyClient } from 'apify-client'; const nonblank = z.string().refine(value => value.trim().length > 0); const inputSchema = z.object({ to: nonblank, cc: z.string().optional(), bcc: z.string().optional(), subject: nonblank, text: z.string().optional(), html: z.string().optional(), replyTo: z.string().optional(), attachments: z.array( z.record(z.string(), z.string()), ).optional(), smtpHost: nonblank, smtpPort: z.number().int().min(1).max(65535).default(587), smtpUser: nonblank, smtpPassword: nonblank, useTls: z.boolean().default(true), }).strict().refine( value => Boolean(value.text?.trim() || value.html?.trim()), { message: 'Provide text or html', path: ['text'] }, ); const run_input = inputSchema.parse({ to: 'oncall@example.com', subject: 'Scheduled export ready', text: 'The export is ready for review.', smtpHost: 'smtp.example.com', smtpUser: 'notifications@example.com', smtpPassword: 'example-password-replace-before-use', }); 

Keep credentials out of validation logs. Record failing field paths and your validation message, rather than dumping the rejected object.

Authentication failures, connection refusals, and timeouts still require execution to discover. Those belong behind the attempt budget.

Calculate a conditional event ceiling

Use the published event prices directly. Keep currency arithmetic in integer microdollars so rounding never decides whether another attempt fits.

There's a boundary you can't fill with optimism: the pricing describes memory scaling but doesn't specify allocation rounding or an upper allocation limit. A defensible ceiling therefore needs a verified start-event bound for the memory configuration you'll actually launch.

The function below refuses approval until that bound is supplied. It allows at most one result event per attempt, matching this Actor's confirmation behavior.

Example budget policy, with the memory-dependent bound deliberately unset:

const prices = { FREE: { start: 100_000n, result: 990n }, BRONZE: { start: 77_000n, result: 820n }, SILVER: { start: 53_000n, result: 660n }, GOLD: { start: 30_000n, result: 490n }, PLATINUM: { start: 30_000n, result: 490n }, DIAMOND: { start: 30_000n, result: 490n }, } as const; function positiveInteger(value: number): number { if (!Number.isSafeInteger(value) || value < 1) { throw new Error('Expected a positive safe integer'); } return value; } function eventCeiling( input: unknown, tier: keyof typeof prices, authorizedAttempts: number, verifiedStartEventBound: number | null, ) { const parsedInput = inputSchema.parse(input); const attempts = positiveInteger(authorizedAttempts); if (verifiedStartEventBound === null) return null; const starts = positiveInteger(verifiedStartEventBound); const rate = prices[tier]; const perAttemptMicros = BigInt(starts) * rate.start + rate.result; return { input: parsedInput, authorizedAttempts: attempts, perAttemptMicros, totalMicros: BigInt(attempts) * perAttemptMicros, }; } function usd(micros: bigint): string { return `${micros / 1_000_000n}.${ (micros % 1_000_000n).toString().padStart(6, '0') }`; } const policy = { occurrences: 30, retriesPerOccurrence: 2, recoveryProbes: 1, maxAttemptsPerNotification: 3, failureThreshold: 2, verifiedStartEventBound: null as number | null, }; const authorizedAttempts = positiveInteger(policy.occurrences) * (1 + policy.retriesPerOccurrence) + policy.recoveryProbes; const plan = eventCeiling( run_input, 'FREE', authorizedAttempts, policy.verifiedStartEventBound, ); type ApprovedPlan = NonNullable<typeof plan>; 

This is an event-fee ceiling, conditional on the selected tier, the verified memory bound, and the Actor's result behavior. Keep platform usage in a separate budget.

Pin the memory configuration associated with that bound. Recheck the plan when the tier, pricing, or configuration changes.

Make every launch spend from the same allowance

A scheduler retry limit does little if someone can bypass it with a manual trigger.

Use a stable notification key for the logical email. Scheduled retries and manual attempts must claim against that same key. Give the SMTP configuration its own circuit key, so a persistent configuration failure can suppress other notifications using it.

Implement the following interface in your persistent job store. Its reservation operation must atomically check the attempt allowance, the period budget, and the circuit before reserving money.

Example integration contract for your existing job ledger:

type ReservationRequest = { notificationKey: string; circuitKey: string; periodKey: string; mode: 'regular' | 'probe'; maxAttemptsPerNotification: number; maxAttemptsInPeriod: number; failureThreshold: number; reserveMicros: bigint; periodCeilingMicros: bigint; }; type AttemptReceipt = { runId: string | null; outcome: 'confirmed' | 'failed' | 'review'; runStatus: string | null; statusMessage: string | null; resultRecords: number | null; }; interface BudgetLedger { reserve(request: ReservationRequest): Promise<string | null>; finish(reservationId: string, receipt: AttemptReceipt): Promise<void>; } 

Use a transaction or equivalent atomic operation. A read followed by an independent update lets overlapping workers spend the same balance.

Count a reservation against both money and attempt limits immediately. If the worker disappears, leave it pending for reconciliation. Automatically refunding an uncertain launch makes a network timeout a rather expensive loophole.

The circuit state should survive budget-period changes. Midnight doesn't repair a password.

Put the cap on the start request

The preflight gate controls whether another run may start. The platform-side cap controls what an individual pay-per-event run may charge.

Use both. A run cap can't enforce the shared allowance across separate executions, and a local estimate can't stop an already running execution.

Example launcher using maxTotalChargeUsd on the REST start endpoint:

const confirmationSchema = z.object({ status: z.string(), sentAt: z.string(), messageId: z.string(), }); async function launchNotification( approved: ApprovedPlan, ledger: BudgetLedger, scope: { notificationKey: string; circuitKey: string; periodKey: string; mode: 'regular' | 'probe'; }, ) { const token = process.env.APIFY_TOKEN; if (!token) throw new Error('APIFY_TOKEN is required'); const reservationId = await ledger.reserve({ ...scope, maxAttemptsPerNotification: policy.maxAttemptsPerNotification, maxAttemptsInPeriod: approved.authorizedAttempts, failureThreshold: policy.failureThreshold, reserveMicros: approved.perAttemptMicros, periodCeilingMicros: approved.totalMicros, }); if (reservationId === null) return; const receipt: AttemptReceipt = { runId: null, outcome: 'review', runStatus: null, statusMessage: null, resultRecords: null, }; try { const url = new URL( 'https://api.apify.com/v2/acts/' + 'nimbryxo-studio~smtp-email-sender-for-workflow-notifications/runs', ); url.searchParams.set( 'maxTotalChargeUsd', usd(approved.perAttemptMicros), ); const response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify(approved.input), }); if (!response.ok) { throw new Error(`Start request returned HTTP ${response.status}`); } const started = z.object({ data: z.object({ id: z.string() }), }).parse(await response.json()); receipt.runId = started.data.id; const client = new ApifyClient({ token }); const finished = await client.run(started.data.id).waitForFinish(); if (!finished) return; receipt.runStatus = finished.status; receipt.statusMessage = finished.statusMessage ?? null; const { items } = await client .dataset(finished.defaultDatasetId) .listItems(); receipt.resultRecords = items.length; const records = z.array(confirmationSchema).max(1).safeParse(items); if (finished.status === 'SUCCEEDED' && records.success && records.data.length === 1 && records.data[0].status === 'sent') { receipt.outcome = 'confirmed'; } else if (finished.status === 'FAILED') { receipt.outcome = 'failed'; } } finally { await ledger.finish(reservationId, receipt); } } 

The launcher is defined without dispatching the illustrative email. Invoke it from your worker only after approving the memory bound and supplying the persistent ledger.

Keep status messages in restricted diagnostic storage. They help repair the connection; they shouldn't become a broadly distributed log of notification inputs.

Reconcile each reservation with charged start and result events afterward. Dataset length can't establish the start-event quantity. Retain uncertain reservations until billing and run state resolve them.

Treat a capped dataset as an unfinished receipt

A cap can leave a run with incomplete output. Downstream ingestion should retain the run's completion state alongside its records, rather than marking a job complete merely because a dataset request succeeded.

For SMTP Email Sender for Workflow Notifications, that distinction is particularly awkward: the output is a confirmation of an external side effect.

SMTP acceptance also doesn't prove inbox delivery. Keep those meanings separate in your application.

The decision belongs after inspecting status, status message, and records together:

reserve allowance | capped start | +-- succeeded + valid confirmation --> record SMTP acceptance | +-- failed ------------------------> retain reason; update circuit | +-- interrupted or inconsistent ---> hold reservation; review 

Don't treat every empty dataset as permission to resend. Sending an email and persisting its confirmation cross system boundaries; an interrupted execution needs reconciliation.

The current messageId is empty because the Actor doesn't assign one before sending. It can't serve as your deduplication key. Keep your notification identity in the job ledger, and avoid claiming exactly-once delivery from this interface.

Open the circuit, then leave a recovery path

Configuration failures usually need a repair. Transient connection failures may justify bounded retries.

Preserve the server's error text for diagnosis, but resist building a supposedly reliable taxonomy from undocumented message fragments. Treat unknown failures conservatively, with an operator-controlled retry decision.

Your ledger's finish operation should update the failure streak atomically. Once the configured threshold is reached, regular reservations stop. Ambiguous attempts should also block automatic resends pending review.

A recovery probe needs explicit authorization in the ledger. Admit it atomically, charge it against the same period budget, and prevent concurrent probes. Close the circuit after a confirmed result; keep it open after another failure.

Changing credentials alone shouldn't silently restart the backlog.

Before the next scheduled attempt

Budget the recurring schedule before enabling it. The policy above includes normal occurrences, their permitted retries, and a recovery allowance. Those are authorized starts, even when you expect most retries to remain unused.

Reset financial allowances at the intended accounting boundary. Keep pending reservations and unresolved notification identities intact across that boundary.

Before restoring the schedule:

  • Validate the complete input locally.
  • Verify the start-event bound against the pinned memory configuration.
  • Route scheduled and manual launches through the shared ledger.
  • Apply the event cap to every start request.
  • Preserve terminal status, diagnostic text, and confirmation state together.
  • Reconcile event charges separately from platform usage.
  • Admit a controlled recovery probe before releasing queued notifications.

An empty dataset is a poor spending report. Your launch ledger should know what happened before the inbox does.