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​

Zyntem 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.zyntem.dev/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)
);
}