id: stripe-failed-payment-and-dispute-desk
namespace: company.billing
description: |
Verify every Stripe webhook against its signing secret, then act on the two billing events that cost
money when nobody looks at them: a failed invoice payment gets an AI-written recovery email tailored to
the decline reason, and a new dispute reaches finance on Slack with its evidence deadline.
variables:
company_name: Acme Cloud
billing_from_email: billing@acme.example
support_email: support@acme.example
gemini_model: gemini-3.5-flash-lite
tasks:
- id: verify_event
type: io.kestra.plugin.stripe.webhook.HandleEvent
description: Fails the execution when the Stripe-Signature header does not match
the raw request body.
apiKey: "{{ secret('STRIPE_API_KEY') }}"
payload: "{{ read(trigger.uri) }}"
signatureHeader: "{{ (trigger.headers['Stripe-Signature'] ??
trigger.headers['stripe-signature']) | first }}"
endpointSecret: "{{ secret('STRIPE_WEBHOOK_SECRET') }}"
- id: skip_duplicate_delivery
type: io.kestra.plugin.core.execution.Exit
description: Stripe can deliver the same event more than once, so an event id
handled in the last 7 days ends the run here.
runIf: "{{ kv('stripe-event-' ~ outputs.verify_event.id, errorOnMissing=false)
is not null }}"
state: SUCCESS
- id: label_execution
type: io.kestra.plugin.core.execution.Labels
labels:
stripe_event_type: "{{ outputs.verify_event.type }}"
stripe_event_id: "{{ outputs.verify_event.id }}"
- id: route_event
type: io.kestra.plugin.core.flow.Switch
value: "{{ outputs.verify_event.type }}"
cases:
invoice.payment_failed:
- id: fetch_billing_context
type: io.kestra.plugin.core.flow.Parallel
tasks:
- id: get_customer
type: io.kestra.plugin.stripe.customer.Get
apiKey: "{{ secret('STRIPE_API_KEY') }}"
customerId: "{{ fromJson(outputs.verify_event.raw).data.object.customer }}"
- id: list_recent_payments
type: io.kestra.plugin.stripe.payment.ListIntents
description: The newest PaymentIntent of the customer carries the decline reason
of the failed charge.
apiKey: "{{ secret('STRIPE_API_KEY') }}"
customer: "{{ fromJson(outputs.verify_event.raw).data.object.customer }}"
limit: 3
- id: draft_recovery_email
type: io.kestra.plugin.ai.completion.ChatCompletion
runIf: "{{ fromJson(outputs.verify_event.raw).data.object.customer_email is not
empty }}"
provider:
type: io.kestra.plugin.ai.provider.GoogleGemini
apiKey: "{{ secret('GEMINI_API_KEY') }}"
modelName: "{{ vars.gemini_model }}"
configuration:
temperature: 0.3
responseFormat:
type: JSON
jsonSchema:
type: object
required:
- subject
- body
properties:
subject:
type: string
body:
type: string
messages:
- type: SYSTEM
content: |
You write short payment recovery emails for {{ vars.company_name }}.
Use only the facts you are given. Never invent amounts, dates, discounts, links or policies.
Do not include any link or URL: a button to the payment page is added below your text.
Explain the decline reason in plain words and give the one action that fixes it.
Match the tone to the attempt: a friendly heads-up on the first failure, clearer urgency
after that, and on the final retry say plainly that no further automatic retries will happen.
Write in the customer's preferred language when one is given, otherwise in English.
Keep the body under 120 words, separate paragraphs with a blank line, no placeholders.
Return JSON with `subject` and `body`.
- type: USER
content: |
{%- set invoice = fromJson(outputs.verify_event.raw).data.object -%}
{%- set customer = outputs.get_customer.customerData -%}
{%- set payment = outputs.list_recent_payments.paymentIntents | first -%}
Customer name: {{ invoice.customer_name ?? customer.name ?? 'unknown' }}
Customer since: {{ (customer.created * 1000) | date('MMMM yyyy') }}
Preferred languages: {{ customer.preferred_locales is empty ? 'none' : customer.preferred_locales | join(', ') }}
Invoice: {{ invoice.number ?? invoice.id }} for {{ (invoice.amount_due / 100) | numberFormat('#,##0.00') }} {{ invoice.currency | upper }}
Billed for: {{ (invoice.lines.data | first).description ?? 'subscription' }}
Failed attempt number: {{ invoice.attempt_count }}
Next automatic retry: {{ invoice.next_payment_attempt is null ? 'none, this was the final retry' : ((invoice.next_payment_attempt * 1000) | date('EEEE d MMMM yyyy', timeZone='UTC')) }}
Decline reason: {{ payment.last_payment_error.decline_code ?? payment.last_payment_error.code ?? 'not provided' }} ({{ payment.last_payment_error.message ?? 'no message' }})
Support contact: {{ vars.support_email }}
- id: send_recovery_email
type: io.kestra.plugin.email.MailSend
runIf: "{{ fromJson(outputs.verify_event.raw).data.object.customer_email is not
empty }}"
host: "{{ secret('SMTP_HOST') }}"
port: "{{ secret('SMTP_PORT') }}"
username: "{{ secret('SMTP_USERNAME') }}"
password: "{{ secret('SMTP_PASSWORD') }}"
transportStrategy: SMTP
from: "{{ vars.billing_from_email }}"
to: "{{ fromJson(outputs.verify_event.raw).data.object.customer_email }}"
subject: "{{ outputs.draft_recovery_email.jsonOutput.subject }}"
plainTextContent: |
{{ outputs.draft_recovery_email.jsonOutput.body }}
Update your payment details: {{ fromJson(outputs.verify_event.raw).data.object.hosted_invoice_url }}
htmlTextContent: |
{% for paragraph in outputs.draft_recovery_email.jsonOutput.body | replace({'&': '&'}) | replace({'<': '<', '>': '>'}) | split('\n\n') -%}
<p>{{ paragraph | replace({'\n': '<br>'}, regexp=true) }}</p>
{% endfor -%}
<p><a href="{{ fromJson(outputs.verify_event.raw).data.object.hosted_invoice_url }}" style="display:inline-block;padding:10px 18px;background:#635bff;color:#ffffff;border-radius:6px;text-decoration:none">Update payment details</a></p>
- id: escalate_if_unrecoverable
type: io.kestra.plugin.core.flow.If
description: A human takes over when Stripe has stopped retrying or the customer
cannot be emailed.
condition: "{{
fromJson(outputs.verify_event.raw).data.object.next_payment_attempt
is null or
fromJson(outputs.verify_event.raw).data.object.customer_email is
empty }}"
then:
- id: alert_churn_risk
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: |
{%- set invoice = fromJson(outputs.verify_event.raw).data.object -%}
:rotating_light: *Churn risk:* payment for invoice {{ invoice.number ?? invoice.id }} failed{{ invoice.next_payment_attempt is null ? ' on the final retry' : '' }}.
*Customer:* {{ invoice.customer_name ?? invoice.customer }} ({{ invoice.customer_email ?? 'no email on file, recovery email not sent' }})
*Amount:* {{ (invoice.amount_due / 100) | numberFormat('#,##0.00') }} {{ invoice.currency | upper }} after {{ invoice.attempt_count }} attempt(s)
<https://dashboard.stripe.com/{{ invoice.livemode ? '' : 'test/' }}invoices/{{ invoice.id }}|Open the invoice in Stripe>
charge.dispute.created:
- id: alert_new_dispute
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: |
{%- set dispute = fromJson(outputs.verify_event.raw).data.object -%}
{%- set evidence = {
'fraudulent': 'Proof the cardholder authorized it: AVS and CVC results, IP address and device, earlier undisputed payments with the same card.',
'product_not_received': 'Carrier, tracking number and delivery confirmation, or access logs for a digital product.',
'product_unacceptable': 'The product description at purchase, the return policy shown at checkout, and your messages with the customer.',
'duplicate': 'Evidence that both charges are separate purchases, or that the duplicate was already refunded.',
'subscription_canceled': 'The cancellation policy accepted at signup, and usage after the claimed cancellation date.',
'credit_not_processed': 'The refund policy and why it does not apply, or proof that the refund was issued.'
} -%}
:warning: *New dispute:* {{ (dispute.amount / 100) | numberFormat('#,##0.00') }} {{ dispute.currency | upper }}, reason `{{ dispute.reason }}`
*Respond by:* {{ (dispute.evidence_details.due_by * 1000) | date('EEE d MMM yyyy, HH:mm z', timeZone='UTC') }}
*Evidence to gather:* {{ evidence[dispute.reason] ?? 'Receipt, the terms accepted at purchase, and all communication with the customer.' }}
*Charge:* `{{ dispute.charge }}`{{ dispute.is_charge_refundable ? ' (still refundable, so accepting may be cheaper than fighting a small amount)' : '' }}
<https://dashboard.stripe.com/{{ dispute.livemode ? '' : 'test/' }}disputes/{{ dispute.id }}|Open the dispute in Stripe>
defaults:
- id: log_ignored_event
type: io.kestra.plugin.core.log.Log
message: "Ignoring Stripe event {{ outputs.verify_event.type }} ({{
outputs.verify_event.id }}): no handler for this type."
- id: mark_event_processed
type: io.kestra.plugin.core.kv.Set
description: Written last, so that a run which failed halfway is processed again
when the event is resent.
key: "stripe-event-{{ outputs.verify_event.id }}"
value: "{{ outputs.verify_event.type }}"
ttl: P7D
errors:
- id: alert_on_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: |
:x: Stripe billing desk failed in execution {{ execution.id }} (event `{{ outputs.verify_event.id ?? 'not read' }}`).
{{ (errorLogs() | first).message | abbreviate(300) ?? 'See the execution logs in Kestra.' }}
triggers:
- id: stripe_webhook
type: io.kestra.plugin.core.trigger.Webhook
description: Stores the body untouched in internal storage, because the
signature is computed over the exact bytes Stripe sent.
key: "{{ secret('STRIPE_WEBHOOK_KEY') }}"
fetchType: STORE