Skip to content

Stripe Integration

Receive and route Stripe webhooks for payments, subscriptions, and other events.

Setup

1. Create a Source in WebhookRelay

bash
curl -X POST https://api.webhookrelay.com/api/organizations/{orgId}/sources \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Stripe Production",
    "slug": "stripe",
    "verificationConfig": {
      "type": "stripe",
      "secret": "whsec_..."
    }
  }'

Save your webhook URL:

https://api.webhookrelay.com/ingest/{orgSlug}/stripe

2. Configure Stripe Webhook

  1. Go to Stripe Dashboard
  2. Click Add endpoint
  3. Enter your WebhookRelay URL
  4. Select events to receive
  5. Click Add endpoint
  6. Copy the Signing secret (starts with whsec_)
  7. Update your WebhookRelay source with this secret

3. Create Routes

bash
# Create destination for your payment handler
curl -X POST https://api.webhookrelay.com/api/organizations/{orgId}/destinations \
  -d '{"name": "Payment Service", "url": "https://api.myapp.com/webhooks/stripe"}'

# Create route
curl -X POST https://api.webhookrelay.com/api/organizations/{orgId}/routes \
  -d '{"name": "Stripe to Payment Service", "sourceId": "src_...", "destinationIds": ["dst_..."]}'

Signature Verification

Stripe uses a custom signature scheme with timestamps to prevent replay attacks.

The signature is sent in the Stripe-Signature header:

Stripe-Signature: t=1492774577,v1=5257a869...

WebhookRelay automatically verifies this when configured:

json
{
  "verificationConfig": {
    "type": "stripe",
    "secret": "whsec_..."
  }
}

Timestamp Tolerance

Stripe signatures include a timestamp. WebhookRelay rejects webhooks older than 5 minutes by default to prevent replay attacks.

Common Events

payment_intent.succeeded

Triggered when a payment is successful.

json
{
  "id": "evt_1234567890",
  "object": "event",
  "type": "payment_intent.succeeded",
  "data": {
    "object": {
      "id": "pi_1234567890",
      "object": "payment_intent",
      "amount": 2000,
      "currency": "usd",
      "status": "succeeded",
      "customer": "cus_1234567890",
      "metadata": {
        "order_id": "12345"
      }
    }
  },
  "livemode": true,
  "created": 1234567890
}

customer.subscription.created

Triggered when a subscription is created.

json
{
  "id": "evt_1234567890",
  "object": "event",
  "type": "customer.subscription.created",
  "data": {
    "object": {
      "id": "sub_1234567890",
      "object": "subscription",
      "customer": "cus_1234567890",
      "status": "active",
      "items": {
        "data": [{
          "price": {
            "id": "price_1234567890",
            "product": "prod_1234567890"
          }
        }]
      },
      "current_period_start": 1234567890,
      "current_period_end": 1237246290
    }
  },
  "livemode": true
}

invoice.payment_failed

Triggered when a payment fails.

json
{
  "id": "evt_1234567890",
  "object": "event",
  "type": "invoice.payment_failed",
  "data": {
    "object": {
      "id": "in_1234567890",
      "object": "invoice",
      "customer": "cus_1234567890",
      "subscription": "sub_1234567890",
      "amount_due": 2000,
      "attempt_count": 1,
      "next_payment_attempt": 1234657890
    }
  }
}

Transform Examples

Slack Alert for Payment Success

javascript
function transform(payload) {
  const payment = payload.data.object;
  const amount = (payment.amount / 100).toFixed(2);
  const currency = payment.currency.toUpperCase();

  return {
    blocks: [
      {
        type: "header",
        text: {
          type: "plain_text",
          text: "Payment Received"
        }
      },
      {
        type: "section",
        fields: [
          {
            type: "mrkdwn",
            text: `*Amount:*\n${currency} ${amount}`
          },
          {
            type: "mrkdwn",
            text: `*Customer:*\n${payment.customer}`
          }
        ]
      },
      {
        type: "context",
        elements: [
          {
            type: "mrkdwn",
            text: `Payment ID: ${payment.id}`
          }
        ]
      }
    ]
  };
}

Database Format

javascript
function transform(payload) {
  const event = payload;
  const data = event.data.object;

  return {
    stripe_event_id: event.id,
    event_type: event.type,
    object_id: data.id,
    object_type: data.object,
    customer_id: data.customer,
    amount: data.amount,
    currency: data.currency,
    status: data.status,
    livemode: event.livemode,
    created_at: new Date(event.created * 1000).toISOString(),
    metadata: data.metadata || {}
  };
}

Alert for Failed Payments

javascript
function transform(payload) {
  const invoice = payload.data.object;
  const amount = (invoice.amount_due / 100).toFixed(2);

  return {
    channel: "#billing-alerts",
    username: "Stripe Bot",
    icon_emoji: ":warning:",
    attachments: [{
      color: "danger",
      title: "Payment Failed",
      fields: [
        { title: "Customer", value: invoice.customer, short: true },
        { title: "Amount", value: `$${amount}`, short: true },
        { title: "Attempts", value: invoice.attempt_count.toString(), short: true },
        { title: "Subscription", value: invoice.subscription || "N/A", short: true }
      ],
      footer: `Invoice ${invoice.id}`,
      ts: Math.floor(Date.now() / 1000)
    }]
  };
}

Filter Examples

Production Events Only

json
{
  "name": "Live Mode Only",
  "logic": "and",
  "conditions": [
    {
      "field": "livemode",
      "operator": "equals",
      "value": "true"
    }
  ]
}

Payment Events

json
{
  "name": "Payment Events",
  "logic": "or",
  "conditions": [
    {
      "field": "type",
      "operator": "starts_with",
      "value": "payment_intent"
    },
    {
      "field": "type",
      "operator": "starts_with",
      "value": "charge"
    }
  ]
}

Subscription Events

json
{
  "name": "Subscription Events",
  "logic": "and",
  "conditions": [
    {
      "field": "type",
      "operator": "starts_with",
      "value": "customer.subscription"
    }
  ]
}

High-Value Payments

json
{
  "name": "High Value ($100+)",
  "logic": "and",
  "conditions": [
    {
      "field": "type",
      "operator": "equals",
      "value": "payment_intent.succeeded"
    },
    {
      "field": "data.object.amount",
      "operator": "greater_than",
      "value": "10000"
    }
  ]
}

Testing

Using Stripe CLI

Test locally with the Stripe CLI:

bash
# Forward Stripe webhooks to WebhookRelay
stripe listen --forward-to https://api.webhookrelay.com/ingest/{orgSlug}/stripe-test

# Trigger test events
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.created

Test Mode Webhook

Create a separate source for test mode:

bash
curl -X POST .../sources \
  -d '{
    "name": "Stripe Test",
    "slug": "stripe-test",
    "verificationConfig": {
      "type": "stripe",
      "secret": "whsec_test_..."
    }
  }'

Best Practices

  1. Separate live and test: Use different sources for live and test webhooks

  2. Handle idempotency: Use event.id to prevent duplicate processing

  3. Verify livemode: Always check livemode to avoid processing test events in production

  4. Store event IDs: Log Stripe event IDs for debugging and reconciliation

  5. Handle retries: Stripe retries failed webhooks; ensure your handlers are idempotent

  6. Monitor signatures: Failed signature verifications may indicate misconfigured secrets

Troubleshooting

Signature Verification Failed

  1. Verify you're using the signing secret (starts with whsec_)
  2. Don't use the API key instead of the webhook secret
  3. Use the correct secret for the environment (live vs test)

Missing Events

  1. Check which events are selected in Stripe webhook settings
  2. Verify filters aren't blocking expected events
  3. Check Stripe's webhook logs for delivery attempts

Duplicate Events

  1. Stripe may retry failed deliveries
  2. Implement idempotency using event.id
  3. Check for multiple webhook endpoints configured

Released under the MIT License.