DEV Community

JedidiahRhodes8293
JedidiahRhodes8293

Posted on

Scoped Keys vs Shared Secrets — Monthly Usage Statement PDF Email

Short answer: for a marketplace that emails monthly usage statements and watches prepaid balances, choose a credential scoped to the statement delivery job over a secret shared with every notification. A shared secret is simpler to deploy, but its exposure can affect both invoices and the alerts meant to prevent balances from running out unattended. The example below uses TypeScript in a Node.js worker; the key decision is the credential boundary, not the PDF library.

Approach Pick it when Exposure and operations
Scoped statement-delivery credential Statements have their own queue and access policy A leaked delivery key need not grant access to balance alerts; maintain separate rotation and delivery checks.
Shared notification credential The operation is small and separate credentials are not yet supported Fewer secrets to provision, but one compromised key reaches more customer mail; rotation affects both workflows.

How should you generate and email a monthly usage statement PDF?

Pick the scoped one when possible. In a marketplace, the same account may receive a monthly PDF and an urgent low-balance warning. Those messages have different clocks: a statement can wait for a retry, while a warning loses value as the balance approaches zero. Give the statement worker only the ability to submit statement mail, and keep balance-alert delivery behind a different identity. OWASP's secrets guidance supports restricting access to the minimum needed and planning for rotation.

The diagram in words: a monthly scheduler creates one job per customer and accounting month; a snapshot reader resolves billable usage; a PDF renderer consumes that frozen snapshot; a statement-specific mail worker delivers the attachment; an outbox records what happened. The alert path reads balance thresholds independently. Neither path needs the other's mail credential. Suppose a customer crosses a low-balance threshold while the statement worker is retrying a PDF render. The alert must still be able to send, and its success must be measured separately; a green monthly batch says nothing about whether the warning arrived. That's the operational reason to isolate delivery identities, not a preference for extra configuration.

When is one shared secret defensible?

Use it only when the delivery system cannot issue narrower credentials and the team can explicitly accept the wider blast radius. It reduces provisioning work today. It also couples emergency rotation to the alert channel, so a statement-delivery incident could interrupt the notifications that protect prepaid accounts. Document that coupling and make replacement credentials testable before switching production traffic. This is a temporary architectural constraint, not an argument that monthly mail and balance warnings are the same workload.

One key, two failure domains.

Build one repeatable customer-month job

The scheduler should enqueue a customer ID and a UTC month, not pass an email address or totals that may have changed by the time the worker starts. Define the accounting window as a half-open interval, [start, end), and store a finalized usage snapshot keyed by customer and month. That snapshot is the input to both the PDF and its delivery record. It also makes a retry predictable: recomputing usage after a correction could otherwise email two different statements under the same month label.

Here is the worker boundary. The storage layer enforces uniqueness for (customerId, month), and claimDelivery atomically grants only one worker the right to send. Its lease must expire for recovery; a retry after an uncertain mail-provider response still requires reconciliation with delivery receipts before sending again. The interfaces are application-owned, so the example does not assume a particular PDF or mail service.

type StatementJob = { customerId: string; month: string }; // YYYY-MM in UTC
type Snapshot = { id: string; customerId: string; month: string; totalUnits: number };

interface Statements {
  finalized(customerId: string, month: string): Promise<Snapshot>;
  claimDelivery(snapshotId: string): Promise<boolean>;
  markSent(snapshotId: string, receiptId: string): Promise<void>;
  markRetryable(snapshotId: string, reason: string): Promise<void>;
}

interface Mailer {
  send(input: {
    toCustomerId: string;
    attachment: Uint8Array;
    idempotencyKey: string;
  }): Promise<{ receiptId: string }>;
}

async function deliverStatement(
  job: StatementJob,
  store: Statements,
  renderPdf: (snapshot: Snapshot) => Promise<Uint8Array>,
  mailer: Mailer,
): Promise<void> {
  if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(job.month)) throw new Error("Invalid UTC month");
  const snapshot = await store.finalized(job.customerId, job.month);
  if (!(await store.claimDelivery(snapshot.id))) return;

  try {
    const attachment = await renderPdf(snapshot);
    const receipt = await mailer.send({
      toCustomerId: job.customerId,
      attachment,
      idempotencyKey: snapshot.id,
    });
    await store.markSent(snapshot.id, receipt.receiptId);
  } catch (error) {
    await store.markRetryable(snapshot.id, String(error));
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

The mailer must actually honor that idempotency key, or the delivery store must reconcile receipts before a retry. A database claim alone cannot make an external send exactly once: the process can die after the mail service accepts the message but before markSent. That gap matters more than a polished PDF template. Picture the sequence: the service accepts a message at 00:02, the worker exits at 00:03, and a fresh worker sees an expired lease at 00:04. Those times illustrate the failure window, not a promised delivery latency. If the recipient receives two PDFs, an identical filename will not undo the confusion; check the receipt or provider-side idempotency record before sending again.

Retry carefully.

Schedule one run after the month closes in the marketplace's defined accounting timezone, convert its boundaries to UTC, then enqueue each eligible customer exactly once. Test month-end and daylight-saving transitions against the agreed timezone. A failed render should retry without touching mail; an uncertain send goes to reconciliation. Keep attachment access limited, set retention deliberately, and avoid logging recipient addresses or PDF contents. For a PDF containing billing details, use an authenticated download link instead of an attachment if the recipient's mailbox is not an acceptable storage location under your data policy.

Measure snapshot_ready, pdf_rendered, mail_accepted, and delivery_confirmed as separate states. Count stalled customer-month jobs and alert on age, not just worker errors: an empty queue can mean the scheduler never ran. Run a deployment test with one synthetic account to verify the month boundary, credential scope, attachment, and receipt path without mailing real customers. Balance alerts need their own freshness signal so a healthy statement run never masks an alert outage.

Limits of the scoped approach

The limitation of scoped credentials is operational overhead: you have two access policies, two rotations, and two paths to exercise in deployment tests. A team unable to monitor both paths should first establish independent delivery signals; splitting keys without checking either workflow can leave the prepaid alert silent. Separate credentials narrow a compromise, but they don't repair incorrect usage totals, prove that a customer opened an email, or guarantee exactly-once delivery. Those require snapshot validation, receipt reconciliation, and a clear customer support path for corrections. Start with the scope boundary, then rehearse a lost receipt and a revoked credential before trusting the monthly schedule.

Sources

References

Top comments (0)