Skip to main content

Stripe Webhooks

Receives billing lifecycle events from Stripe. This endpoint is public (no authentication required) -- security is enforced through Stripe signature verification.

Receive a Stripe event

POST /v1/webhooks/stripe

Processes incoming Stripe webhook events for billing lifecycle management.

Headers

HeaderRequiredDescription
Stripe-SignatureYesStripe webhook signature (t=<timestamp>,v1=<signature>)

Request body

The raw Stripe event JSON payload (max 64 KiB).

Signature verification

FiscalAPI verifies every Stripe webhook delivery:

  1. Extract the Stripe-Signature header
  2. Parse the timestamp (t=) and signature (v1=)
  3. Verify the timestamp is within ±5 minutes of server time
  4. Compute HMAC-SHA256(webhook_secret, "{timestamp}.{payload}")
  5. Compare against the provided signature using constant-time comparison

Requests that fail signature verification are rejected with 400 Bad Request.

Processed events

Stripe EventAction
invoice.payment_succeededAccount restored if previously suspended
invoice.payment_failedAccount suspended, notification email sent
customer.subscription.deletedAccount suspended, notification email sent
customer.subscription.updatedUpdate logged

Idempotency

Each event is processed exactly once. Duplicate deliveries (same Stripe event ID) return 200 OK without reprocessing.

Response

StatusDescription
200 OKEvent processed (or duplicate)
400 Bad RequestInvalid signature or payload

Example

# Stripe sends this automatically -- you do not call this endpoint directly.
# Configure your Stripe webhook to point to:
# https://api.fiscalapi.com/v1/webhooks/stripe
#
# Required Stripe events:
# - invoice.payment_succeeded
# - invoice.payment_failed
# - customer.subscription.deleted
# - customer.subscription.updated

Verifying signatures (reference)

If you want to understand how Stripe signature verification works:

import hmac
import hashlib
import time

def verify_stripe_signature(payload, header, secret, tolerance=300):
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp = parts["t"]
signature = parts["v1"]

# Check timestamp tolerance (±5 minutes)
if abs(time.time() - int(timestamp)) > tolerance:
return False

expected = hmac.new(
secret.encode(),
f"{timestamp}.{payload}".encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
const crypto = require("crypto");

function verifyStripeSignature(payload, header, secret, tolerance = 300) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=", 1))
);
const timestamp = parts["t"];
const signature = parts["v1"];

// Check timestamp tolerance (±5 minutes)
if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > tolerance) {
return false;
}

const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${payload}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}