---
title: "Stripe Webhook - Event reception"
method: POST
path: "/webhooks/stripe"
tags: ["Webhooks", "Stripe"]
---

# Stripe Webhook - Event reception

`POST /webhooks/stripe`

## Request

- Base URL: `https://api.faireplace.com/api`
- URL: `https://api.faireplace.com/api/webhooks/stripe`
- Auth: none required

Endpoint to receive Stripe webhooks and process payment events.

## ⚠️ IMPORTANT: Security

This endpoint **DOES NOT USE** standard Bearer API key authentication.
Instead, it uses **Stripe signature verification** to ensure
that requests genuinely come from Stripe.

### Signature verification

Each webhook is verified with HMAC SHA256 according to Stripe documentation:
1. Extract the timestamp and v1 signature from the `Stripe-Signature` header
2. Build the signed payload: `timestamp + "." + payload_body`
3. Compute HMAC SHA256 with the webhook secret (`STRIPE_WEBHOOK_SECRET`)
4. Constant-time comparison with the received signature
5. Validate the timestamp (reject webhooks > 5 minutes old)

**Stripe-Signature header format**:
```
t=1234567890,v1=abc123def456...
```

### Replay attack protection

Webhooks with a timestamp too old (> 5 minutes) are rejected
to prevent replay attacks.

## Handled events

### `checkout.session.completed`

**Triggered**: When a user completes a payment via Stripe Checkout Session.

**Actions**:
1. Extract metadata (`tenant_id`, `user_id`, `package_id`)
2. Idempotency check (avoid double processing)
3. Retrieve the package from DB via `stripe_price_id`
4. Credit the user's account
5. Create a `purchase` type transaction

**Idempotency**:
- Check by `payment_intent_id` in existing transactions
- Check by `session_id` in transaction reasons
- If already processed, returns 200 OK without action

**Payload example**:
```json
{
  "id": "evt_test_123",
  "type": "checkout.session.completed",
  "data": {
    "object": {
      "id": "cs_test_123",
      "payment_intent": "pi_test_123",
      "amount_total": 2990,
      "metadata": {
        "tenant_id": "tenant_123",
        "user_id": "user_456",
        "package_id": "550e8400-e29b-41d4-a716-446655440000"
      },
      "line_items": {
        "data": [{
          "price": {
            "id": "price_test_123"
          }
        }]
      }
    }
  }
}
```

### `payment_intent.succeeded`

**Triggered**: When a Stripe PaymentIntent is confirmed successfully.

**Actions**:
- Logging for individual payment traceability
- No action on credits (handled by `checkout.session.completed`)

**Usage**: Primarily for individual payments (non-credits).

### `payment_intent.payment_failed`

**Triggered**: When a payment fails.

**Actions**:
- Logging for traceability
- No action on credits (no deduction)

## Stripe Dashboard Configuration

For this endpoint to work, you must configure a webhook in Stripe Dashboard:

1. Go to **Developers** → **Webhooks**
2. Click **Add endpoint**
3. URL: `https://api.faireplace.com/api/webhooks/stripe` (or dev URL for testing)
4. Select the events:
   - ✅ `checkout.session.completed`
   - ✅ `payment_intent.succeeded`
   - ✅ `payment_intent.payment_failed`
5. Copy the **Signing secret** (`whsec_...`) and configure it in `STRIPE_WEBHOOK_SECRET`

## Responses

The endpoint always returns **200 OK** for handled or ignored events,
per Stripe recommendations. This prevents Stripe from retrying
webhooks indefinitely.

**Exceptions**:
- **401 Unauthorized**: Invalid or missing signature
- **400 Bad Request**: Invalid payload or missing metadata
- **500 Internal Server Error**: Processing error

## Request example

```bash
curl -X POST https://api.faireplace.com/v1/webhooks/stripe \
  -H "Stripe-Signature: t=1234567890,v1=abc123def456..." \
  -H "Content-Type: application/json" \
  -d '{
    "id": "evt_test_123",
    "type": "checkout.session.completed",
    "data": {
      "object": {
        "id": "cs_test_123",
        "payment_intent": "pi_test_123",
        "amount_total": 2990,
        "metadata": {
          "tenant_id": "tenant_123",
          "user_id": "user_456",
          "package_id": "550e8400-e29b-41d4-a716-446655440000"
        }
      }
    }
  }'
```

**Response**:
```json
{
  "received": true
}
```

## Logs and traceability

All webhooks are logged with:
- Event type
- Stripe Event ID
- Session ID or Payment Intent ID
- Tenant ID and User ID (from metadata)
- Processing result (success, idempotency, error)

## Error handling

In case of error during webhook processing:
1. The error is logged with all details
2. A 500 response is returned to Stripe
3. Stripe will automatically retry the webhook (up to 3 attempts)

**Important**: Validation errors (missing metadata, etc.) return 400
and will not be retried by Stripe.

## Headers

- `Stripe-Signature` string, required

## Request body

- object: Stripe Webhook Event. The exact structure depends on the event type. See the Stripe documentation for full details.
  - `id` string, required: Unique identifier of the Stripe event (starts with `evt_`). Used for idempotency and traceability.
  - `type` string, required: Stripe event type. Supported types: - `checkout.session.completed`: Payment via Checkout Session completed - `payment_intent.succeeded`: PaymentIntent confirmed successfully - `payment_intent.payment_failed`: Payment failed
  - `data` object, required: Event data. Contains the relevant Stripe object (Checkout Session, PaymentIntent, etc.) in the `object` property.
    - `object` object, required: Stripe object related to the event. For `checkout.session.completed`, contains the Checkout Session. For `payment_intent.succeeded`, contains the PaymentIntent.

## Response `200`

Webhook received and processed successfully.

Always returns 200 OK even for unhandled events,
per Stripe recommendations.

- object
  - `received` boolean, required: Indicates that the webhook has been received. Always `true` on success.

## Other responses

- `400`: Invalid request. **Possible causes**: - Invalid JSON payload - Missing required fields (`id`, `type`, `data.object`) - Missing metadata in Checkout Session (`tenant_id`, `user_id`) - Unsupported event format
- `401`: Invalid or missing signature. **Possible causes**: - Missing `Stripe-Signature` header - Invalid signature format - Signature does not match the payload - Timestamp too old (> 5 minutes) ⚠️ **Security**: Webhooks without a valid signature are always rejected.
- `500`: Internal error during webhook processing. **Possible causes**: - Error retrieving the package - Error adding credits - Database error ⚠️ **Note**: Stripe will automatically retry webhooks that return 500 (up to 3 attempts over 3 days).

---

[API](https://skmtc.dev/faireplace/apis/faireplace-property-management-api.md) · [All operations](https://skmtc.dev/faireplace/apis/faireplace-property-management-api/llms.txt) · [OpenAPI document](https://skmtc.dev/faireplace/apis/faireplace-property-management-api/revisions/69de39762919?raw)
