A few months ago I was on the hook for rewriting the postmortem template our on-call rotation used after any webhook delivery incident. We run a job-processing platform that fans out customer webhooks, and when the delivery workers backed up during a Redis failover, a batch of customers had their webhooks delayed by anywhere from twenty minutes to six hours. Standard stuff, annoying but recoverable. What actually stopped me was the apology email our support lead pasted into the incident channel for review, because buried in the Jinja2 template that generated it was a conditional branch keyed on something called customer.churn_risk_score.

The branch gave a bigger service credit to accounts our data team had flagged as likely to cancel. Customers who looked "sticky" got the contractual minimum. It wasn't malicious, someone in growth had added it two years earlier to reduce churn after outages, and nobody had touched it since. But once I saw it, I couldn't unsee it: we weren't calculating what we owed people for downtime, we were calculating how much it would cost to keep them. That distinction sent me down a strange rabbit hole that started, of all places, with a philosophy essay about a Justin Bieber song.

I'd read a piece by Suranjan Das on decodingvibes.com a week earlier, applying Kant's categorical imperative to the 2015 song "Sorry." His argument, stripped of the pop-culture framing, is that an apology only counts as a moral apology if you'd still make it even when there's nothing to gain from it. If the apology depends on getting something back, forgiveness, a second chance, the relationship, it's what Kant called a hypothetical imperative: conditional, strategic, and not really about the wrong done at all. I read it as a fun aside on a Friday. Then I opened our postmortem template on Monday and realized we'd built the exact same structure into a Python service.

A service credit that changes based on how likely a customer is to leave isn't a credit for the outage, it's a retention discount wearing an SLA's clothes. If the number would be different for a customer who was definitely staying versus definitely leaving, you haven't calculated what you owe, you've calculated what you're willing to pay to keep them.

The template that started it

Our incident emails were generated by a small internal service, nothing fancy, a Flask endpoint that took an incident record and rendered it through Jinja2 before handing it off to SendGrid. The template had grown over two years of on-call engineers adding "just one more thing" to make customers feel better after an outage. Nobody had ever sat down and asked what the email was actually for.

Here's roughly what the broken version looked like, trimmed down to the part that mattered:

# templates/incident_apology.html.j2 (rendered via Flask + Jinja2)
subject_template = "We're sorry for the disruption — here to make it right"

body_template = """
Hi {{ customer_name }},

We know outages are frustrating, and we don't want this to affect your
relationship with the platform. As a valued customer, we're offering you
a {{ credit_percentage }}% service credit on invoice {{ invoice_id }}.

{% if customer.churn_risk_score > 0.6 %}
We'd also like to offer a complimentary month to make sure you stick around.
{% endif %}

Thanks for your patience,
The Platform Team
"""

The churn_risk_score came from a model the growth team ran nightly against usage decay and support ticket volume. It had nothing to do with how long the customer's webhooks had actually been delayed. Two accounts with identical downtime could get different credit amounts purely because one of them looked like it might churn. Once I traced it back, the fix wasn't complicated, it was just uncomfortable to admit we'd been doing it wrong:

# templates/incident_report.html.j2 — rewritten
subject_template = "Incident report {{ incident_id }}: what happened and what we're fixing"

body_template = """
Hi {{ customer_name }},

Between {{ start_time }} and {{ end_time }} UTC, webhook deliveries to
{{ endpoint_url }} were delayed by an average of {{ avg_delay_minutes }}
minutes due to {{ root_cause_summary }}.

Per section {{ sla_section }} of your service agreement, this qualifies
for a {{ credit_percentage }}% credit, applied automatically to invoice
{{ invoice_id }} regardless of plan or renewal status.

Full root cause and remediation timeline: {{ postmortem_url }}

{{ engineer_name }}, on-call engineer
"""

The rewrite deletes the churn_risk_score branch entirely and replaces "we'd like to" language with plain statements of fact: what happened, what it cost the customer, what we're doing about it. Notice the sentence "regardless of plan or renewal status" isn't decoration, it's the whole point. The credit is now a function of the SLA math, not of whether the account looks like a flight risk.


Hypothetical apologies versus categorical ones

Kant's framework, at least the part relevant here, splits obligations into two kinds. A hypothetical imperative says "do X if you want Y," it's conditional on some goal, and if the goal disappears, so does the reason to act. A categorical imperative says "do X, full stop," regardless of whether it gets you anything. Das's essay on the Bieber song makes the case that a genuine apology has to be categorical: you owe the acknowledgment because of the wrong, not because of what admitting it might buy you back.

He points out that the song's central question, "is it too late to say sorry," only makes sense if the apology's value is tied to whether it can still secure something, forgiveness, a second chance, the relationship. Das writes that once an apology "is evaluated by whether it can secure forgiveness and reconciliation, it ceases to be an action grounded in duty." Swap "forgiveness" for "retention" and you've described our churn-risk branch exactly.

In Kant's terms, treating someone "as an end and never merely as a means" means their standing as a person doesn't depend on what they can do for you. Applied to an SLA credit, that means the amount owed can't legitimately depend on how useful the customer's continued business is to you.

What made this click for me wasn't the philosophy vocabulary, it was realizing our engineering culture already has a name for the categorical version of this: blameless postmortems. The whole point of a blameless postmortem is that you document the failure the same way whether the engineer involved is a star performer or on a performance plan, whether the customer is your biggest account or your smallest. We'd internalized that principle for internal incident reviews and then completely ignored it for the customer-facing email that comes out of the same incident.

Rewriting the credit calculation

The email template was the visible symptom, but the actual bug lived one layer down, in the service that computed credit_percentage before it ever reached Jinja2. That function took a churn_score parameter as an input, which meant the retention logic wasn't a rendering quirk, it was baked into the business logic that decided what customers were owed. Fixing the email without fixing this function would have just moved the hypothetical imperative somewhere less visible.

Here's the version we shipped, which computes credit purely from downtime minutes against the customer's contracted SLA tier:

from dataclasses import dataclass

SLA_TIERS = {
    "standard": [(30, 0.05), (120, 0.10), (360, 0.25)],
    "enterprise": [(15, 0.10), (60, 0.25), (240, 0.50)],
}

@dataclass
class IncidentImpact:
    downtime_minutes: int
    sla_tier: str

def calculate_sla_credit(impact: IncidentImpact) -> float:
    thresholds = SLA_TIERS.get(impact.sla_tier, SLA_TIERS["standard"])
    for minutes, credit_pct in thresholds:
        if impact.downtime_minutes <= minutes:
            return credit_pct
    return 1.0  # full invoice credit past the worst tier

No customer object, no churn score, no growth-model dependency at all. The function only knows two things: how long the customer was affected, and what tier of contract they signed. That's a deliberate constraint, not an oversight, if a future teammate wants to add a "loyalty multiplier" back in, they have to change the function signature to do it, which means it shows up in code review instead of quietly living inside a Jinja2 conditional nobody reads twice.

We also stopped letting support agents manually bump credit percentages during customer calls, which used to happen more than I'd like to admit. The credit is computed once, from the incident record, and the number in the email has to match the number in the invoice system exactly. If a customer pushes back and asks for more, that's a separate conversation about goodwill, and we're honest that it's goodwill and not the SLA obligation.

What a categorical postmortem actually requires

Once we agreed the credit calculation had to be duty-driven instead of outcome-driven, we still had a template problem: engineers writing postmortems under deadline pressure default to the same soft, relationship-preserving language that got us into this mess. "We're sorry for any inconvenience this may have caused" tells a customer nothing and commits us to nothing. So we turned the fix into a checklist that every customer-facing postmortem has to satisfy before it goes out, which our incident tooling now enforces as required fields rather than optional prose.

The list is short on purpose, because a fifteen-item checklist just becomes another thing people skim past under deadline pressure:

  • Impact statement naming the exact affected functionality and customer-visible symptoms
  • Timeline in UTC, from detection to full recovery, with no ranges wider than five minutes
  • Root cause, stated as the actual technical failure, not "human error" or "unforeseen circumstances"
  • Contributing factors that made detection or recovery slower than it should have been
  • Remediation items, each with a named owner and a due date, not a target quarter
  • The SLA section and credit percentage this incident qualifies under, computed from the function above

Every one of those items has to be true independent of who the customer is. That's the categorical test in practice: if a section of the postmortem would read differently for a customer we're worried about losing versus one we're not, it's written wrong. It's a genuinely useful filter to run a draft through before it goes out, and it catches soft language faster than any style guide has for us.

The phrasing difference, side by side

The line-level rewrite ended up mattering more than I expected. Two sentences can convey the same facts and still send completely opposite signals about whether the credit is contingent on the customer's goodwill toward us or ours toward them. We started keeping a running table of phrases to catch in review, since the retention-driven habit is easy to slip back into when you're tired and just want the email out the door.

Here's a sample of the kind of side-by-side comparison we now use when reviewing drafts:

Retention-driven phrasingDuty-driven phrasing
"We hope this doesn't affect our relationship""Here is exactly what broke and when"
"As a valued customer, we'd like to offer...""You are owed a 25% credit per SLA section 4.2"
"We're sorry for any inconvenience this may have caused""We are responsible for this outage"
"We'd love the chance to make this right""This is what we're fixing, and by when"

The right-hand column reads less warm, and that's not an accident, it's the whole shift. Warmth that's calibrated to how much we want to keep the account isn't actually warmth, it's negotiation. Customers who've been through a few of our incidents have told us, unprompted, that the drier version reads as more trustworthy, because it doesn't sound like it's trying to talk them out of being angry.

The tradeoff nobody warned me about

None of this was free. Support ticket volume on incident emails went up for about six weeks after we shipped the new template, not because customers were angrier, but because the old emails were vague enough that people rarely had a specific follow-up question, while the new ones give them exact numbers and timelines to push back on. A customer who now sees "25% credit per SLA section 4.2" instead of "a token of our appreciation" is far more likely to ask why it isn't 50%, and honestly, they should.

The other cost was internal. Engineers writing postmortems under deadline pressure sometimes flat out don't know the root cause yet when the email needs to go out, and the checklist doesn't have a graceful way to say "we don't know" without it reading as evasive.

Stripping the soft, relationship-preserving language out of an apology makes it more accurate, but it also removes the cushioning that used to absorb ambiguity. If your team isn't ready to say "we don't fully know the root cause yet" in plain words, a categorical postmortem template will expose that gap faster than a hypothetical one ever did.

We ended up adding an explicit "root cause status: confirmed / under investigation" field rather than let engineers fudge it with vague language, which fixed most of that friction. It's a small addition, but it matters, because a duty-driven postmortem still has to be honest about the limits of what you know, not just honest about what you're willing to admit.

What I'd check next

I don't think every team needs to go read eighteenth-century moral philosophy to fix their incident emails, and I'd be lying if I said the Kant framing was necessary rather than just a useful accident of what I happened to read that week. The actual, portable lesson is narrower: if any number or sentence in your customer-facing incident communication changes based on a signal the customer never sees, like a churn score, an account tier that isn't in the contract, or a support rep's read of how upset someone sounds on the phone, that's worth auditing, because it means the apology and the obligation have quietly become two different things wearing the same template.

If you're going through this yourself, the fastest thing to grep for is any conditional in your incident-email code path that references a customer attribute unrelated to the incident itself. Churn score, lifetime value, plan tier if it's not actually written into your SLA, anything like that is a sign the "apology" is doing retention work it was never designed to do. This doesn't mean postmortems should be cold or legally sterile, we still write them for actual humans who are annoyed at us, it just means the facts and the credit shouldn't move depending on who's reading them.

Sources: Suranjan Das — A Kantian Critique of "Sorry" by Justin Bieber