TalkIDE receives and processes inbound webhook events from Stripe. The endpoint is authenticated via Stripe-Signature header (not JWT). Supports events: payment_method.attached, customer.updated, invoice.paid, invoice.payment_failed, invoice.voided. Idempotent — duplicate events are silently accepted (200 OK).
- Endpoint:
POST /api/v1/stripe/webhook— excluded from Spring Security JWT filter viapermitAll()rule. - Authentication: Stripe signs every webhook payload with a HMAC-SHA256 signature in the
Stripe-Signatureheader. BE verifies usingstripe.webhooks.constructEvent(payload, sigHeader, webhookSecret). Invalid signatures return 400. - Webhook signing secret is injected via env var
STRIPE_WEBHOOK_SECRET. For local development, use Stripe CLI (stripe listen --forward-to localhost:9090/api/v1/stripe/webhook) which provides a temporary signing secret. - Idempotence: Stripe may deliver the same event multiple times. BE checks
stripe_webhook_eventstable (eventidas unique key). If the event ID already exists, return 200 immediately without re-processing. - All events are stored in
stripe_webhook_eventsfor audit trail and replay debugging. - Timeout constraint: Stripe expects a 2xx response within 30 seconds. BE should process synchronously and return quickly. Heavy processing (e.g. sending email) must be async (Spring
@Asyncor queue). - Related: UC-10001 — payment method registration. UC-10004 — invoices.
Supported Events
| Stripe event | TalkIDE action |
|---|---|
payment_method.attached | Log to stripe_webhook_events; no additional action (payment method is set as default via UC-10001 POST /payment-method already). |
customer.updated | Log to stripe_webhook_events; if email changed on Customer, sync to display in GET /payment-method billing email (no DB column — fetched live from Stripe; this event signals a Stripe-side change e.g. from Stripe Dashboard). |
invoice.paid | Log to stripe_webhook_events; emit internal event (stub: log). Future: trigger email receipt to billing email. |
invoice.payment_failed | Log to stripe_webhook_events; emit internal event (stub: log + optionally toast via SSE). Future: dunning flow. |
invoice.voided | Log to stripe_webhook_events; no additional action. |
sequenceDiagram
participant Stripe
participant BE
participant DB
Stripe->>+BE: POST /api/v1/stripe/webhook <br> Stripe-Signature: t=...,v1=... <br> raw JSON payload
BE->>BE: verify Stripe-Signature using STRIPE_WEBHOOK_SECRET
alt signature invalid or timestamp stale (> 5 min)
BE-->>Stripe: 400 Bad Request <br> (signature verification failed)
end
BE->>BE: parse event type + event id from payload
BE->>DB: SELECT id FROM stripe_webhook_events WHERE stripe_event_id = eventId
alt event already processed (idempotence check)
BE-->>Stripe: 200 OK <br> { "received": true }
end
BE->>DB: INSERT INTO stripe_webhook_events (stripe_event_id, type, payload_json, received_at)
alt event type = payment_method.attached
BE->>BE: log event (no further action in MVP)
end
alt event type = customer.updated
BE->>BE: log event <br> (billing email is fetched live from Stripe on GET /payment-method — no sync needed)
end
alt event type = invoice.paid
BE->>BE: log event + emit internal InvoicePaidEvent (stub: Slf4j log)
end
alt event type = invoice.payment_failed
BE->>BE: log event + emit internal InvoicePaymentFailedEvent (stub: Slf4j log)
end
alt event type = invoice.voided
BE->>BE: log event (no further action)
end
alt unhandled event type
BE->>BE: log "unhandled Stripe event: {type}" at INFO level
end
BE->>-Stripe: 200 OK <br> { "received": true }
POST /api/v1/stripe/webhook — raw request body (Stripe JSON payload), Content-Type: application/json
Request headers:
Stripe-Signature: t=1715000000,v1=abc123def456...,v0=...
Content-Type: application/json
Request body (example — invoice.paid):
{
"id": "evt_1PqXyz...",
"object": "event",
"type": "invoice.paid",
"data": {
"object": {
"id": "in_1PqXyz...",
"customer": "cus_1PqXyz...",
"amount_paid": 2900,
"currency": "usd",
"status": "paid",
"hosted_invoice_url": "https://invoice.stripe.com/..."
}
},
"created": 1715000000
}
200 OK WebhookAckResponse (success or idempotent duplicate):
{
"received": true
}
400 Bad Request (signature verification failed):
{
"status": 400,
"code": "STRIPE_SIGNATURE_INVALID",
"message": "Webhook signature verification failed"
}
DB Schema
New table: stripe_webhook_events (migration file: 0023-create-stripe-webhook-events.xml)
<changeSet id="0023-create-stripe-webhook-events" author="system">
<createTable tableName="stripe_webhook_events">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="stripe_event_id" type="VARCHAR(255)">
<constraints nullable="false" unique="true"/>
</column>
<column name="type" type="VARCHAR(100)">
<constraints nullable="false"/>
</column>
<column name="payload_json" type="TEXT">
<constraints nullable="false"/>
</column>
<column name="received_at" type="TIMESTAMP WITH TIME ZONE" defaultValueComputed="NOW()">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="stripe_webhook_events" indexName="idx_stripe_webhook_events_stripe_event_id" unique="true">
<column name="stripe_event_id"/>
</createIndex>
</changeSet>
Security Configuration
SecurityConfig.java — add to permitAll() matchers:
/api/v1/stripe/webhook
The endpoint must NOT be behind any JWT auth filter. Stripe does not send JWT tokens.
Local Development
Use Stripe CLI to forward webhooks to local BE:
stripe listen --forward-to http://localhost:9090/api/v1/stripe/webhook
The CLI prints a temporary webhook signing secret (whsec_...). Set it as STRIPE_WEBHOOK_SECRET in your local env / application-local.yaml.
Backend
Validations
| Field | Constraints | Note |
|---|---|---|
Stripe-Signature header | present, valid HMAC-SHA256, timestamp within 5 min of now | 400 STRIPE_SIGNATURE_INVALID if invalid |
| Event JSON | parseable Stripe Event object | 400 if JSON is malformed |
stripe_event_id (idempotence) | unique per event; if duplicate → return 200 immediately | No re-processing |
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
Valid Stripe-Signature, invoice.paid event | POST /webhook is called | 200 OK { "received": true }; event stored in stripe_webhook_events; InvoicePaidEvent logged |
Valid Stripe-Signature, payment_method.attached event | POST /webhook is called | 200 OK; event stored; no additional processing |
Valid Stripe-Signature, customer.updated event | POST /webhook is called | 200 OK; event stored; billing email re-fetch happens live on next GET /payment-method |
| Valid Stripe-Signature, same event ID sent twice (Stripe retry) | second POST /webhook is called | 200 OK; second event is a no-op (idempotence check hits DB, returns immediately); only one row in stripe_webhook_events |
Invalid or missing Stripe-Signature header | POST /webhook is called | 400 STRIPE_SIGNATURE_INVALID returned |
| Stripe-Signature timestamp older than 5 minutes (replay attack) | POST /webhook is called | 400 STRIPE_SIGNATURE_INVALID returned (Stripe SDK checks timestamp tolerance) |
Unknown event type (e.g. charge.succeeded) | POST /webhook is called with valid signature | 200 OK; event stored; “unhandled Stripe event: charge.succeeded” logged at INFO |
| Malformed JSON body (Stripe-Signature present) | POST /webhook is called | 400 returned (Stripe SDK constructEvent throws); event not stored |
Valid Stripe-Signature, invoice.payment_failed event | POST /webhook is called | 200 OK; event stored; InvoicePaymentFailedEvent logged |
Thanks for the feedback.