Internal Documentation internal
TalkIDE internal documentation

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 via permitAll() rule.
  • Authentication: Stripe signs every webhook payload with a HMAC-SHA256 signature in the Stripe-Signature header. BE verifies using stripe.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_events table (event id as unique key). If the event ID already exists, return 200 immediately without re-processing.
  • All events are stored in stripe_webhook_events for 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 @Async or queue).
  • Related: UC-10001 — payment method registration. UC-10004 — invoices.

Supported Events

Stripe eventTalkIDE action
payment_method.attachedLog to stripe_webhook_events; no additional action (payment method is set as default via UC-10001 POST /payment-method already).
customer.updatedLog 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.paidLog to stripe_webhook_events; emit internal event (stub: log). Future: trigger email receipt to billing email.
invoice.payment_failedLog to stripe_webhook_events; emit internal event (stub: log + optionally toast via SSE). Future: dunning flow.
invoice.voidedLog 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

FieldConstraintsNote
Stripe-Signature headerpresent, valid HMAC-SHA256, timestamp within 5 min of now400 STRIPE_SIGNATURE_INVALID if invalid
Event JSONparseable Stripe Event object400 if JSON is malformed
stripe_event_id (idempotence)unique per event; if duplicate → return 200 immediatelyNo re-processing

Test Cases

GIVENWHENTHEN
Valid Stripe-Signature, invoice.paid eventPOST /webhook is called200 OK { "received": true }; event stored in stripe_webhook_events; InvoicePaidEvent logged
Valid Stripe-Signature, payment_method.attached eventPOST /webhook is called200 OK; event stored; no additional processing
Valid Stripe-Signature, customer.updated eventPOST /webhook is called200 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 called200 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 headerPOST /webhook is called400 STRIPE_SIGNATURE_INVALID returned
Stripe-Signature timestamp older than 5 minutes (replay attack)POST /webhook is called400 STRIPE_SIGNATURE_INVALID returned (Stripe SDK checks timestamp tolerance)
Unknown event type (e.g. charge.succeeded)POST /webhook is called with valid signature200 OK; event stored; “unhandled Stripe event: charge.succeeded” logged at INFO
Malformed JSON body (Stripe-Signature present)POST /webhook is called400 returned (Stripe SDK constructEvent throws); event not stored
Valid Stripe-Signature, invoice.payment_failed eventPOST /webhook is called200 OK; event stored; InvoicePaymentFailedEvent logged

Was this page helpful?

Thanks for the feedback.