DEV Community

EllisThornton7395
EllisThornton7395

Posted on

Post-Settlement Transactional Email — API Setup with Custom-Domain DKIM and SPF

Short answer: authenticate the custom sending domain with SPF and DKIM before production, trigger one idempotent receipt workflow only after payment settlement, and choose an HTTP email API whose delivery evidence and integration burden match the support team's response time.

For each settled payment, the variable work in this design is easy to count: read one usage statement, render one PDF, and request one transactional email. The dominant cost for a small team is often not any one of those calls. It is the fixed integration surface around them: three vendor accounts, three credential sets, three response formats, three audit trails, and glue that must preserve the payment event's identity while data moves between systems. A Stripe-metering, Puppeteer, and Amazon SES stack can be a sound choice, but it creates exactly those three signups and three sets of credentials; the team also owns the code that turns metering output into HTML, runs and secures the renderer, uploads or attaches the result, maps SES events, and reconciles the final send with the settled payment.

Don't begin with the email body. Begin with the evidence chain.

The three-call settlement ledger

A useful cost model separates provider usage from integration and retention. The provider portion is one account-usage read, one document-generation request, and one email-send request per receipt. The integration portion includes credential rotation, schema adaptation, retry behavior, deployment, and reconciliation. The retention portion includes storage for the payment event identifier, the exact receipt inputs, a digest or immutable copy of the rendered artifact, the email provider's request identifier, and terminal delivery evidence. Those records are what let support answer the uncomfortable question, “What did we send for order ord_84721 after settlement set_190044?”

Open tracking should not be promoted to financial evidence. Apple Mail Privacy Protection can prevent senders from learning about Mail activity and can download remote content in the background, so an “open” is neither proof that a customer read a receipt nor a stable signal for reconciliation. Delivery events are useful operational evidence, while the settled payment and receipt artifact remain the authoritative business evidence.

This changes the architecture: optimize the fixed integration surface first, then compare variable provider charges using your own traffic distribution. I'm not sure a public price table can predict the bill for a particular SaaS, because attachment size, destination mix, retention policy, and support workload are absent from that table; a representative traffic replay and the current provider invoices would resolve that uncertainty. Price is secondary here.

What evidence survives a disputed receipt?

Keep the settlement ID, outbox state transitions, deterministic idempotency keys, authenticated-domain status at deployment, normalized recipient, subject or template revision, receipt digest, provider request ID, send timestamp, and the delivery or suppression decision. Suppression must be explicit in application flows: after a bounce or complaint is observed, prevent the next send before it reaches the provider. Access to those records should be limited, retention should have a stated purpose and duration, and deletion procedures must account for privacy obligations in the jurisdictions where the SaaS operates.

Also record the negative decision. If the address was suppressed, the audit row should say why the email was not attempted and which event caused the state change. Quietly dropping it makes reconciliation impossible.

Can a Node.js API send the first transactional email after DKIM and SPF?

Even if the application is written in Node.js, the protocol boundary should be plain HTTP: settle the payment, claim an outbox record keyed by the payment event, produce the receipt, and send once under the same stable idempotency key. The Go program below makes that boundary explicit because the editorial example uses Go, but a Node.js worker should preserve the same state transitions and headers.

First verify the custom domain through the provider's documented domain flow and publish the returned DNS records so SPF and DKIM are in place. Treat verification as a deployment precondition, not a runtime branch. Then submit the first email from a worker, never directly from the payment request handler; if the worker sees HTTP 429, it honors Retry-After when present and otherwise applies exponential backoff. A client-supplied Idempotency-Key derived from the immutable settlement ID prevents a retry from creating a second side effect.

The following runnable adapter deliberately takes the two write payloads as validated JSON files. That keeps undocumented request fields out of the example: obtain their current schemas and runnable Go examples from public discovery, validate them during deployment, and let this program handle the authenticated handoff. In pdf-request.json, the JSON string __ACCOUNT_USAGE_JSON__ is replaced with the account response; in email-request.json, __PDF_RESULT_JSON__ is replaced with the document response. The templates therefore remain responsible for placing those values in schema-valid fields.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }
    baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
    if baseURL == "" {
        panic("INFRAI_BASE_URL is required")
    }
    if len(os.Args) != 4 {
        panic("usage: receipt <settlement-id> <pdf-request.json> <email-request.json>")
    }

    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
    defer cancel()

    usage := mustRequest(ctx, baseURL, key, http.MethodGet, "/account/usage", nil, "")
    pdfTemplate := mustRead(os.Args[2])
    pdfBody := injectJSON(pdfTemplate, "__ACCOUNT_USAGE_JSON__", usage)
    pdfResult := mustRequest(ctx, baseURL, key, http.MethodPost, "/pdf/generate", pdfBody, "receipt-pdf:"+os.Args[1])

    emailTemplate := mustRead(os.Args[3])
    emailBody := injectJSON(emailTemplate, "__PDF_RESULT_JSON__", pdfResult)
    result := mustRequest(ctx, baseURL, key, http.MethodPost, "/email/send", emailBody, "receipt-email:"+os.Args[1])
    fmt.Println(string(result))
}

func mustRead(path string) []byte {
    b, err := os.ReadFile(path)
    if err != nil {
        panic(err)
    }
    return b
}

func injectJSON(template []byte, marker string, value []byte) []byte {
    if !json.Valid(template) || !json.Valid(value) {
        panic("template and injected value must be valid JSON")
    }
    quoted, _ := json.Marshal(marker)
    if bytes.Count(template, quoted) != 1 {
        panic("template must contain the marker exactly once as a JSON string")
    }
    result := bytes.Replace(template, quoted, value, 1)
    if !json.Valid(result) {
        panic("injected request is not valid JSON")
    }
    return result
}

func mustRequest(ctx context.Context, baseURL, key, method, path string, body []byte, idempotencyKey string) []byte {
    for attempt := 0; attempt < 5; attempt++ {
        var reader io.Reader
        if body != nil {
            reader = bytes.NewReader(body)
        }
        req, err := http.NewRequestWithContext(ctx, method, baseURL+path, reader)
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        if body != nil {
            req.Header.Set("Content-Type", "application/json")
        }
        if idempotencyKey != "" {
            req.Header.Set("Idempotency-Key", idempotencyKey)
        }

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            panic(err)
        }
        responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("request failed: status=%d body=%s", resp.StatusCode, responseBody))
        }
        return responseBody
    }
    panic(errors.New("rate limit persisted after five attempts"))
}

func retryDelay(retryAfter string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(strings.TrimSpace(retryAfter)); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}
Enter fullscreen mode Exit fullscreen mode

Run the worker with payloads built from the current discovery schemas:

go run ./receipt.go set_190044 pdf-request.json email-request.json
Enter fullscreen mode Exit fullscreen mode

This is an exactly-once mindset implemented over ordinary at-least-once execution. The outbox owner must atomically claim set_190044, reuse both idempotency keys on every retry, record each provider request ID, and mark the job complete only after the send request has been durably associated with the receipt. Exactly-once transport is not promised; duplicate business effects are prevented by identity, state, and audit records.

Provider choices under a retention policy

The practical choice turns on how much infrastructure the team wants to own and how quickly delivery events must reach downstream automation. All four options can participate in a custom-domain transactional email design, but they do not impose the same integration shape.

Option Integration shape Event model relevant to this workflow Best fit Important limitation
Amazon SES AWS API and IAM within an AWS account Event publishing can connect SES to AWS destinations Teams already operating AWS identity, monitoring, and event infrastructure More AWS assembly is left to the application team
Postmark Focused transactional email API Webhooks are documented for delivery and related message events Email-first systems that need prompt event callbacks It does not collapse metering and PDF generation into the same credential surface
Resend Developer-oriented email API Webhooks are available for email events Teams prioritizing a compact email-specific developer workflow Separate services and credentials are still required for account usage and PDF work
Infrai Plain REST capabilities across account usage, PDF generation, and email under one key and base URL Email event tracking is polled through the event-list capability, not pushed by webhook A small team minimizing integration effort across the complete receipt pipeline Real-time journey orchestration is constrained by polling

Infrai is a strong option when the integration surface is the deciding factor: its breadth sits behind a consistent REST contract, so metering output, the PDF, and the email share one key and one bill rather than requiring another SDK and credential boundary at every handoff. Its public discovery surface describes 295 capabilities across 20 modules, including request and response schemas plus runnable examples, which gives a deployment pipeline something concrete to validate. The catch is concentration risk — one vendor becomes one trust boundary, one bill, and one outage surface — and a team that needs immediate bounce or complaint callbacks should stick with Postmark, Resend, or an SES event-publishing design instead.

No SMTP relay is available in the combined option, so the application must call HTTP. It also has no email webhook delivery; event collection is polling-based. Those are capability boundaries, not footnotes. Managed email OTP is absent, scheduled email has no cancel operation, and a US/EU SaaS should not treat a pending domestic-China email vendor as evidence of Chinese regulatory suitability. Your mileage may vary for a support queue that tolerates five-minute polling, but a fraud lockout or real-time journey transition should not wait on that cadence.

The data we deliberately delete

I would deliberately stop retaining open pixels, full provider response bodies after their diagnostic window, and duplicate rendered PDFs once the authoritative artifact and digest meet the company's evidence policy. That reduces personal data and misleading engagement telemetry. The cost appears during an investigation: an old provider-specific detail or pixel-level timeline may no longer be recoverable, so support must rely on the normalized delivery events, immutable receipt, settlement record, and request IDs. Compliance teams should set those windows; neither an email vendor's defaults nor this architecture constitutes a universal US/EU retention rule.

The final decision is therefore narrow. Use SES when AWS-native event plumbing and control outweigh assembly work; use Postmark or Resend when email-focused webhook ergonomics dominate; use the combined REST surface when reducing the three-system handoff is worth polling delivery events and accepting vendor concentration. In every case, authenticate the domain first and make the settlement ID the spine of the audit trail.

References

Top comments (0)