[{"data":1,"prerenderedAt":334},["ShallowReactive",2],{"blog-best-practices/webhooks":3},{"id":4,"title":5,"body":6,"category":310,"date":311,"dateModified":311,"description":312,"draft":313,"extension":314,"faq":315,"featured":313,"headerVariant":319,"image":320,"keywords":320,"meta":321,"navigation":322,"ogDescription":323,"ogTitle":320,"path":324,"readTime":325,"schemaOrg":326,"schemaType":327,"seo":328,"sitemap":329,"stem":330,"tags":331,"twitterCard":332,"__hash__":333},"blog/blog/best-practices/webhooks.md","Webhook Security Best Practices: Validation, Signatures, and Safe Processing",{"type":7,"value":8,"toc":299},"minimark",[9,16,25,30,33,48,57,61,64,73,77,80,136,145,149,152,161,165,168,187,196,200,203,212,218,240,244,247,268,287],[10,11,12],"tldr",{},[13,14,15],"p",{},"The #1 webhook security best practice is verifying signatures using HMAC with constant-time comparison. Process webhooks asynchronously and idempotently. Return 200 quickly and process in the background. Never trust webhook data without verification.",[17,18,19],"quotable-box",{},[20,21,22],"blockquote",{},[13,23,24],{},"\"An unverified webhook is an open door. Anyone can forge a request to your endpoint. Always verify the signature before trusting the payload.\"",[26,27,29],"h2",{"id":28},"best-practice-1-verify-webhook-signatures-5-min","Best Practice 1: Verify Webhook Signatures 5 min",[13,31,32],{},"Never process webhooks without verifying the signature:",[34,35,37],"code-block",{"label":36},"Stripe webhook verification",[38,39,44],"pre",{"className":40,"code":42,"language":43},[41],"language-text","import Stripe from 'stripe';\nimport { buffer } from 'micro';\n\nconst stripe = new Stripe(process.env.STRIPE_SECRET_KEY);\nconst webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;\n\nexport async function POST(req) {\n  const body = await buffer(req);\n  const signature = req.headers.get('stripe-signature');\n\n  let event;\n\n  try {\n    // Stripe library handles signature verification\n    event = stripe.webhooks.constructEvent(\n      body,\n      signature,\n      webhookSecret\n    );\n  } catch (err) {\n    console.error('Webhook signature verification failed:', err.message);\n    return new Response('Invalid signature', { status: 400 });\n  }\n\n  // Process verified event\n  switch (event.type) {\n    case 'payment_intent.succeeded':\n      await handlePaymentSuccess(event.data.object);\n      break;\n    case 'customer.subscription.deleted':\n      await handleSubscriptionCanceled(event.data.object);\n      break;\n  }\n\n  return new Response('OK', { status: 200 });\n}\n","text",[45,46,42],"code",{"__ignoreMap":47},"",[34,49,51],{"label":50},"Generic HMAC signature verification",[38,52,55],{"className":53,"code":54,"language":43},[41],"import crypto from 'crypto';\n\nfunction verifyWebhookSignature(payload, signature, secret) {\n  const expectedSignature = crypto\n    .createHmac('sha256', secret)\n    .update(payload, 'utf8')\n    .digest('hex');\n\n  // Use constant-time comparison to prevent timing attacks\n  const signatureBuffer = Buffer.from(signature, 'hex');\n  const expectedBuffer = Buffer.from(expectedSignature, 'hex');\n\n  if (signatureBuffer.length !== expectedBuffer.length) {\n    return false;\n  }\n\n  return crypto.timingSafeEqual(signatureBuffer, expectedBuffer);\n}\n\n// Usage\napp.post('/webhooks/provider', express.raw({ type: 'application/json' }), (req, res) => {\n  const signature = req.headers['x-webhook-signature'];\n  const payload = req.body.toString();\n\n  if (!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET)) {\n    return res.status(401).json({ error: 'Invalid signature' });\n  }\n\n  // Process webhook...\n  res.status(200).send('OK');\n});\n",[45,56,54],{"__ignoreMap":47},[26,58,60],{"id":59},"best-practice-2-process-asynchronously-4-min","Best Practice 2: Process Asynchronously 4 min",[13,62,63],{},"Return quickly and process in the background:",[34,65,67],{"label":66},"Async webhook processing",[38,68,71],{"className":69,"code":70,"language":43},[41],"import { Queue } from 'bullmq';\n\nconst webhookQueue = new Queue('webhooks');\n\napp.post('/webhooks/stripe', async (req, res) => {\n  // 1. Verify signature first\n  const event = verifyStripeSignature(req);\n  if (!event) {\n    return res.status(400).send('Invalid signature');\n  }\n\n  // 2. Store event for idempotency check\n  const exists = await db.webhookEvent.findUnique({\n    where: { eventId: event.id },\n  });\n\n  if (exists) {\n    // Already processed, return success\n    return res.status(200).send('Already processed');\n  }\n\n  // 3. Queue for async processing\n  await webhookQueue.add('process-webhook', {\n    eventId: event.id,\n    type: event.type,\n    data: event.data,\n  });\n\n  // 4. Return quickly (within 5-10 seconds)\n  return res.status(200).send('Accepted');\n});\n\n// Worker processes webhooks\nconst worker = new Worker('webhooks', async (job) => {\n  const { eventId, type, data } = job.data;\n\n  try {\n    // Process the webhook\n    await processWebhookEvent(type, data);\n\n    // Mark as processed\n    await db.webhookEvent.create({\n      data: {\n        eventId,\n        type,\n        processedAt: new Date(),\n        status: 'completed',\n      },\n    });\n  } catch (error) {\n    // Mark as failed for retry\n    await db.webhookEvent.upsert({\n      where: { eventId },\n      create: { eventId, type, status: 'failed', error: error.message },\n      update: { status: 'failed', error: error.message },\n    });\n    throw error;  // BullMQ will retry\n  }\n});\n",[45,72,70],{"__ignoreMap":47},[26,74,76],{"id":75},"best-practice-3-handle-idempotency-3-min","Best Practice 3: Handle Idempotency 3 min",[13,78,79],{},"Webhooks may be delivered multiple times:",[81,82,83,99],"table",{},[84,85,86],"thead",{},[87,88,89,93,96],"tr",{},[90,91,92],"th",{},"Scenario",[90,94,95],{},"Risk",[90,97,98],{},"Solution",[100,101,102,114,125],"tbody",{},[87,103,104,108,111],{},[105,106,107],"td",{},"Duplicate delivery",[105,109,110],{},"Double processing",[105,112,113],{},"Track event IDs",[87,115,116,119,122],{},[105,117,118],{},"Out-of-order delivery",[105,120,121],{},"Incorrect state",[105,123,124],{},"Check timestamps",[87,126,127,130,133],{},[105,128,129],{},"Retry after timeout",[105,131,132],{},"Duplicate actions",[105,134,135],{},"Return 200 early",[34,137,139],{"label":138},"Idempotent webhook handling",[38,140,143],{"className":141,"code":142,"language":43},[41],"async function processPaymentWebhook(event) {\n  const paymentId = event.data.object.id;\n\n  // Use database transaction with unique constraint\n  try {\n    await db.$transaction(async (tx) => {\n      // Check if already processed\n      const existing = await tx.payment.findUnique({\n        where: { stripePaymentId: paymentId },\n      });\n\n      if (existing?.status === 'completed') {\n        console.log(`Payment ${paymentId} already processed, skipping`);\n        return;\n      }\n\n      // Process payment\n      await tx.payment.upsert({\n        where: { stripePaymentId: paymentId },\n        create: {\n          stripePaymentId: paymentId,\n          amount: event.data.object.amount,\n          status: 'completed',\n          processedAt: new Date(),\n        },\n        update: {\n          status: 'completed',\n          processedAt: new Date(),\n        },\n      });\n\n      // Fulfill order (only if payment newly completed)\n      if (!existing || existing.status !== 'completed') {\n        await fulfillOrder(event.data.object.metadata.orderId);\n      }\n    });\n  } catch (error) {\n    if (error.code === 'P2002') {\n      // Unique constraint violation - already processed\n      console.log('Duplicate webhook, ignoring');\n      return;\n    }\n    throw error;\n  }\n}\n",[45,144,142],{"__ignoreMap":47},[26,146,148],{"id":147},"best-practice-4-validate-webhook-data-4-min","Best Practice 4: Validate Webhook Data 4 min",[13,150,151],{},"Do not trust webhook payload data blindly:",[34,153,155],{"label":154},"Webhook data validation",[38,156,159],{"className":157,"code":158,"language":43},[41],"import { z } from 'zod';\n\n// Define expected webhook schema\nconst stripePaymentSchema = z.object({\n  id: z.string().startsWith('evt_'),\n  type: z.literal('payment_intent.succeeded'),\n  data: z.object({\n    object: z.object({\n      id: z.string().startsWith('pi_'),\n      amount: z.number().positive(),\n      currency: z.string().length(3),\n      customer: z.string().startsWith('cus_').optional(),\n      metadata: z.object({\n        orderId: z.string().uuid(),\n      }),\n    }),\n  }),\n});\n\nasync function handlePaymentWebhook(event) {\n  // Validate webhook structure\n  const result = stripePaymentSchema.safeParse(event);\n  if (!result.success) {\n    console.error('Invalid webhook structure:', result.error);\n    throw new Error('Invalid webhook data');\n  }\n\n  const payment = result.data.data.object;\n\n  // IMPORTANT: Fetch fresh data from Stripe to verify\n  // Do not trust amounts/status from webhook alone\n  const stripePayment = await stripe.paymentIntents.retrieve(payment.id);\n\n  if (stripePayment.status !== 'succeeded') {\n    console.error('Payment not actually succeeded');\n    throw new Error('Payment status mismatch');\n  }\n\n  // Verify amount matches your records\n  const order = await db.order.findUnique({\n    where: { id: payment.metadata.orderId },\n  });\n\n  if (order.expectedAmount !== stripePayment.amount) {\n    console.error('Amount mismatch - possible tampering');\n    throw new Error('Amount verification failed');\n  }\n\n  // Safe to process\n  await fulfillOrder(order.id);\n}\n",[45,160,158],{"__ignoreMap":47},[26,162,164],{"id":163},"best-practice-5-secure-webhook-endpoints-3-min","Best Practice 5: Secure Webhook Endpoints 3 min",[13,166,167],{},"Protect your webhook URLs:",[169,170,171,175,178,181,184],"ul",{},[172,173,174],"li",{},"Use HTTPS only for webhook endpoints",[172,176,177],{},"Add rate limiting to prevent abuse",[172,179,180],{},"Use unpredictable URLs (include random token)",[172,182,183],{},"Log all webhook requests for auditing",[172,185,186],{},"Set appropriate timeouts",[34,188,190],{"label":189},"Secure webhook endpoint setup",[38,191,194],{"className":192,"code":193,"language":43},[41],"import rateLimit from 'express-rate-limit';\n\n// Rate limit webhooks\nconst webhookLimiter = rateLimit({\n  windowMs: 60 * 1000,  // 1 minute\n  max: 100,             // 100 requests per minute\n  message: 'Too many webhook requests',\n});\n\n// Use unpredictable endpoint URL\n// Bad:  /webhooks/stripe\n// Good: /webhooks/stripe/wh_a8f3k2j4m9x7\nconst WEBHOOK_TOKEN = process.env.WEBHOOK_URL_TOKEN;\n\napp.post(\n  `/webhooks/stripe/${WEBHOOK_TOKEN}`,\n  webhookLimiter,\n  express.raw({ type: 'application/json' }),\n  async (req, res) => {\n    // Log for auditing\n    console.log('Webhook received', {\n      ip: req.ip,\n      userAgent: req.headers['user-agent'],\n      timestamp: new Date().toISOString(),\n    });\n\n    // Process webhook...\n  }\n);\n\n// Reject requests to wrong paths\napp.post('/webhooks/*', (req, res) => {\n  res.status(404).send('Not found');\n});\n",[45,195,193],{"__ignoreMap":47},[26,197,199],{"id":198},"best-practice-6-handle-failures-gracefully-4-min","Best Practice 6: Handle Failures Gracefully 4 min",[13,201,202],{},"Implement retry logic and failure handling:",[34,204,206],{"label":205},"Webhook failure handling",[38,207,210],{"className":208,"code":209,"language":43},[41],"// BullMQ retry configuration\nconst webhookQueue = new Queue('webhooks', {\n  defaultJobOptions: {\n    attempts: 5,\n    backoff: {\n      type: 'exponential',\n      delay: 1000,  // 1s, 2s, 4s, 8s, 16s\n    },\n    removeOnComplete: 1000,\n    removeOnFail: 5000,\n  },\n});\n\n// Dead letter queue for failed webhooks\nconst worker = new Worker('webhooks', processWebhook, {\n  connection: redis,\n});\n\nworker.on('failed', async (job, err) => {\n  if (job.attemptsMade >= job.opts.attempts) {\n    // Move to dead letter queue\n    await deadLetterQueue.add('failed-webhook', {\n      originalJob: job.data,\n      error: err.message,\n      failedAt: new Date(),\n    });\n\n    // Alert team\n    await sendAlert({\n      severity: 'high',\n      message: `Webhook processing failed after ${job.attemptsMade} attempts`,\n      eventId: job.data.eventId,\n      error: err.message,\n    });\n  }\n});\n",[45,211,209],{"__ignoreMap":47},[213,214,215],"info-box",{},[13,216,217],{},"External Resources:\nFor more on webhook security, see the\nStripe Webhooks Best Practices\n,\nGitHub Webhook Signature Verification\n, and the\nOWASP Input Validation Cheat Sheet\nfor industry-standard guidance.",[219,220,221,228,234],"faq-section",{},[222,223,225],"faq-item",{"question":224},"What if a provider does not sign webhooks?",[13,226,227],{},"Use IP allowlisting if the provider publishes their IP ranges. Add a secret token in the URL path. For critical webhooks, verify the data by calling the provider's API. Consider if you can use polling instead.",[222,229,231],{"question":230},"How long should I keep webhook logs?",[13,232,233],{},"Keep webhook logs for at least 30 days for debugging and dispute resolution. For compliance (payments, subscriptions), keep them longer (90 days to 7 years depending on requirements). Store only necessary data, not full payloads with PII.",[222,235,237],{"question":236},"Should I use raw body or parsed JSON?",[13,238,239],{},"Use raw body for signature verification (parsing can change formatting). Parse JSON only after signature is verified. Most frameworks need special configuration to access raw body alongside parsed body.",[26,241,243],{"id":242},"further-reading","Further Reading",[13,245,246],{},"Put these practices into action with our step-by-step guides.",[169,248,249,256,262],{},[172,250,251],{},[252,253,255],"a",{"href":254},"/blog/how-to/add-security-headers","Add security headers to your app",[172,257,258],{},[252,259,261],{"href":260},"/blog/checklists/pre-deployment-security-checklist","Pre-deployment security checklist",[172,263,264],{},[252,265,267],{"href":266},"/blog/getting-started/first-scan","Run your first security scan",[269,270,271,277,282],"related-articles",{},[272,273],"related-card",{"description":274,"href":275,"title":276},"API design best practices","/blog/best-practices/api-design","API Security",[272,278],{"description":279,"href":280,"title":281},"Validate all input data","/blog/best-practices/input-validation","Input Validation",[272,283],{"description":284,"href":285,"title":286},"Log webhooks safely","/blog/best-practices/logging","Secure Logging",[288,289,292,296],"cta-box",{"href":290,"label":291},"/","Start Free Scan",[26,293,295],{"id":294},"check-your-webhook-security","Check Your Webhook Security",[13,297,298],{},"Scan for webhook vulnerabilities and misconfigurations.",{"title":47,"searchDepth":300,"depth":300,"links":301},2,[302,303,304,305,306,307,308,309],{"id":28,"depth":300,"text":29},{"id":59,"depth":300,"text":60},{"id":75,"depth":300,"text":76},{"id":147,"depth":300,"text":148},{"id":163,"depth":300,"text":164},{"id":198,"depth":300,"text":199},{"id":242,"depth":300,"text":243},{"id":294,"depth":300,"text":295},"best-practices","2026-02-05","Webhook security best practices. Learn signature validation, HMAC verification, idempotency, timeout handling, and safe webhook processing patterns.",false,"md",[316,317,318],{"question":224,"answer":227},{"question":230,"answer":233},{"question":236,"answer":239},"vibe-green",null,{},true,"Secure your webhook endpoints with proper validation and processing.","/blog/best-practices/webhooks","11 min read","[object Object]","Article",{"title":5,"description":312},{"loc":324},"blog/best-practices/webhooks",[],"summary_large_image","UBYg7JPRi7rWd4pbbjK9quRHbfz97meg41mOBYvzVNc",1775843925116]