{"id":1177,"date":"2026-05-24T17:00:00","date_gmt":"2026-05-24T22:00:00","guid":{"rendered":"https:\/\/tolinku.com\/blog\/?p=1177"},"modified":"2026-03-07T03:35:01","modified_gmt":"2026-03-07T08:35:01","slug":"serverless-webhook-handlers","status":"publish","type":"post","link":"https:\/\/tolinku.com\/blog\/serverless-webhook-handlers\/","title":{"rendered":"Serverless Webhook Handlers for Deep Link Events"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Running a dedicated server for a webhook receiver that handles a few hundred events per day is overkill. You&#39;re paying for idle compute 99% of the time. Serverless functions flip the model: they run only when a webhook arrives, scale automatically during traffic spikes, and cost nothing when idle.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This guide covers deploying <a href=\"https:\/\/tolinku.com\/features\/webhooks\">Tolinku webhook<\/a> handlers on AWS Lambda, Google Cloud Functions, and Cloudflare Workers, with working code for each platform. For the general webhook architecture, see the <a href=\"https:\/\/tolinku.com\/blog\/real-time-event-processing\/\">real-time event processing guide<\/a>. For setup basics, see the <a href=\"https:\/\/tolinku.com\/blog\/webhook-setup-guide\/\">webhook setup guide<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><img decoding=\"async\" src=\"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/platform-webhooks.png\" alt=\"Tolinku webhook configuration for event notifications\">\n<em>The webhooks page with create form, webhook list, and delivery log.<\/em><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why Serverless for Webhooks?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Webhooks are inherently event-driven: nothing happens until an event arrives. Serverless functions match this pattern perfectly.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table>\n<thead>\n<tr>\n<th>Concern<\/th>\n<th>Traditional Server<\/th>\n<th>Serverless<\/th>\n<\/tr>\n<\/thead>\n<tbody><tr>\n<td>Idle cost<\/td>\n<td>Running 24\/7 regardless of traffic<\/td>\n<td>Zero cost when no events<\/td>\n<\/tr>\n<tr>\n<td>Scaling<\/td>\n<td>Manual or auto-scaling with lag<\/td>\n<td>Instant per-request scaling<\/td>\n<\/tr>\n<tr>\n<td>Ops burden<\/td>\n<td>OS patches, runtime updates, monitoring<\/td>\n<td>Managed by the platform<\/td>\n<\/tr>\n<tr>\n<td>Cold starts<\/td>\n<td>None (always running)<\/td>\n<td>100-500ms on first invocation<\/td>\n<\/tr>\n<tr>\n<td>Long-running tasks<\/td>\n<td>Supported<\/td>\n<td>Limited (Lambda: 15 min max)<\/td>\n<\/tr>\n<\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The tradeoff is cold starts and execution time limits. For webhook receivers that verify a signature, parse a payload, and forward to a queue or database, the execution time is well within limits (typically under 1 second).<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">AWS Lambda + API Gateway<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The most common serverless webhook setup. API Gateway provides the HTTPS endpoint; Lambda runs the handler.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Handler Code<\/h3>\n\n\n\n<pre><code class=\"language-typescript\">\/\/ handler.ts\nimport crypto from &#39;crypto&#39;;\n\nconst WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;\n\nexport async function handler(event: any) {\n  const body = event.body;\n  const isBase64 = event.isBase64Encoded;\n  const rawBody = isBase64 ? Buffer.from(body, &#39;base64&#39;) : Buffer.from(body);\n\n  \/\/ Verify signature\n  const signature = event.headers[&#39;x-webhook-signature&#39;];\n  const expected = crypto\n    .createHmac(&#39;sha256&#39;, WEBHOOK_SECRET)\n    .update(rawBody)\n    .digest(&#39;hex&#39;);\n\n  if (signature !== expected) {\n    return { statusCode: 401, body: &#39;Invalid signature&#39; };\n  }\n\n  const webhookEvent = JSON.parse(rawBody.toString());\n\n  \/\/ Process the event\n  await processEvent(webhookEvent);\n\n  return { statusCode: 200, body: &#39;OK&#39; };\n}\n\nasync function processEvent(event: any) {\n  const { event: eventType, timestamp, data } = event;\n  console.log(JSON.stringify({ eventType, timestamp, data }));\n\n  \/\/ Forward to SQS, DynamoDB, or another service\n  \/\/ Example: write to DynamoDB\n  \/\/ await dynamodb.put({ TableName: &#39;webhook-events&#39;, Item: { ... } });\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Deployment with SAM<\/h3>\n\n\n\n<pre><code class=\"language-yaml\"># template.yaml\nAWSTemplateFormatVersion: &#39;2010-09-09&#39;\nTransform: AWS::Serverless-2016-10-31\n\nResources:\n  WebhookFunction:\n    Type: AWS::Serverless::Function\n    Properties:\n      Handler: handler.handler\n      Runtime: nodejs20.x\n      Timeout: 10\n      MemorySize: 256\n      Environment:\n        Variables:\n          WEBHOOK_SECRET: !Ref WebhookSecret\n      Events:\n        Webhook:\n          Type: Api\n          Properties:\n            Path: \/webhooks\/tolinku\n            Method: post\n<\/code><\/pre>\n\n\n\n<pre><code class=\"language-bash\">sam build &amp;&amp; sam deploy --guided\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">After deployment, SAM outputs the API Gateway URL. Use that as your webhook endpoint in Tolinku.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Lambda-Specific Considerations<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Timeout<\/strong>: Set the Lambda timeout to 10 seconds to match Tolinku&#39;s delivery timeout. If your function takes longer than 10 seconds, Tolinku marks the delivery as failed and retries.<\/li>\n<li><strong>Concurrency<\/strong>: Lambda scales automatically. Set reserved concurrency to protect downstream services from being overwhelmed during traffic spikes.<\/li>\n<li><strong>Cold starts<\/strong>: The first invocation after idle periods adds 100-500ms. For Node.js, this is typically under 300ms, well within the 10-second window.<\/li>\n<li><strong>API Gateway<\/strong>: Use a REST API (not HTTP API) if you need request validation or WAF integration. HTTP API is cheaper and faster for simple webhook receivers.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Google Cloud Functions<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Cloud Functions offer a simpler deployment model than Lambda (no API Gateway configuration needed).<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Handler Code<\/h3>\n\n\n\n<pre><code class=\"language-typescript\">\/\/ index.ts\nimport crypto from &#39;crypto&#39;;\nimport type { HttpFunction } from &#39;@google-cloud\/functions-framework&#39;;\n\nconst WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;\n\nexport const webhookHandler: HttpFunction = async (req, res) =&gt; {\n  if (req.method !== &#39;POST&#39;) {\n    res.status(405).send(&#39;Method not allowed&#39;);\n    return;\n  }\n\n  \/\/ Cloud Functions provides the raw body via req.rawBody\n  const rawBody = req.rawBody;\n  if (!rawBody) {\n    res.status(400).send(&#39;No body&#39;);\n    return;\n  }\n\n  \/\/ Verify signature\n  const signature = req.headers[&#39;x-webhook-signature&#39;] as string;\n  const expected = crypto\n    .createHmac(&#39;sha256&#39;, WEBHOOK_SECRET)\n    .update(rawBody)\n    .digest(&#39;hex&#39;);\n\n  if (signature !== expected) {\n    res.status(401).send(&#39;Invalid signature&#39;);\n    return;\n  }\n\n  const event = JSON.parse(rawBody.toString());\n\n  \/\/ Process\n  await processEvent(event);\n\n  res.status(200).send(&#39;OK&#39;);\n};\n\nasync function processEvent(event: any) {\n  console.log(JSON.stringify({\n    event_type: event.event,\n    timestamp: event.timestamp,\n    platform: event.data.platform,\n  }));\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Deployment<\/h3>\n\n\n\n<pre><code class=\"language-bash\">gcloud functions deploy webhookHandler \\\n  --runtime nodejs20 \\\n  --trigger-http \\\n  --allow-unauthenticated \\\n  --set-env-vars WEBHOOK_SECRET=whsec_your_secret_here \\\n  --timeout 10s \\\n  --memory 256MB \\\n  --region us-central1\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The output includes the function&#39;s URL. Use it as your Tolinku webhook endpoint.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Cloud Functions Considerations<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong><code>rawBody<\/code><\/strong>: Cloud Functions preserves the raw request body in <code>req.rawBody<\/code>. Use this for signature verification, not <code>req.body<\/code> (which is pre-parsed).<\/li>\n<li><strong><code>--allow-unauthenticated<\/code><\/strong>: Required because Tolinku sends webhooks without Google Cloud authentication. The <code>X-Webhook-Signature<\/code> header provides authentication.<\/li>\n<li><strong>Gen 2 vs Gen 1<\/strong>: Gen 2 functions (backed by Cloud Run) have better cold start times and concurrency. Prefer Gen 2 for production.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Cloudflare Workers<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Workers run at the edge, close to users. They have no cold starts (always warm) and a generous free tier (100,000 requests\/day).<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Handler Code<\/h3>\n\n\n\n<pre><code class=\"language-typescript\">\/\/ src\/index.ts\nexport default {\n  async fetch(request: Request, env: Env): Promise&lt;Response&gt; {\n    if (request.method !== &#39;POST&#39;) {\n      return new Response(&#39;Method not allowed&#39;, { status: 405 });\n    }\n\n    const rawBody = await request.arrayBuffer();\n    const bodyBytes = new Uint8Array(rawBody);\n\n    \/\/ Verify signature using Web Crypto API\n    const signature = request.headers.get(&#39;x-webhook-signature&#39;);\n    if (!signature) {\n      return new Response(&#39;Missing signature&#39;, { status: 401 });\n    }\n\n    const key = await crypto.subtle.importKey(\n      &#39;raw&#39;,\n      new TextEncoder().encode(env.WEBHOOK_SECRET),\n      { name: &#39;HMAC&#39;, hash: &#39;SHA-256&#39; },\n      false,\n      [&#39;sign&#39;]\n    );\n\n    const expectedBytes = await crypto.subtle.sign(&#39;HMAC&#39;, key, bodyBytes);\n    const expectedHex = Array.from(new Uint8Array(expectedBytes))\n      .map(b =&gt; b.toString(16).padStart(2, &#39;0&#39;))\n      .join(&#39;&#39;);\n\n    if (signature !== expectedHex) {\n      return new Response(&#39;Invalid signature&#39;, { status: 401 });\n    }\n\n    const event = JSON.parse(new TextDecoder().decode(bodyBytes));\n\n    \/\/ Process (non-blocking with waitUntil)\n    const ctx = { waitUntil: (p: Promise&lt;any&gt;) =&gt; p };\n    ctx.waitUntil(processEvent(event, env));\n\n    return new Response(&#39;OK&#39;, { status: 200 });\n  },\n};\n\nasync function processEvent(event: any, env: Env) {\n  \/\/ Forward to a queue, KV store, or external API\n  console.log(`${event.event}: ${event.data.platform || &#39;unknown&#39;}`);\n}\n\ninterface Env {\n  WEBHOOK_SECRET: string;\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Deployment<\/h3>\n\n\n\n<pre><code class=\"language-bash\">npx wrangler deploy\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Configure the secret:<\/p>\n\n\n\n<pre><code class=\"language-bash\">npx wrangler secret put WEBHOOK_SECRET\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Workers Considerations<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>No cold starts<\/strong>: Workers are always warm. First-byte time is typically under 5ms.<\/li>\n<li><strong>Web Crypto API<\/strong>: Workers don&#39;t have Node.js <code>crypto<\/code>. Use the Web Crypto API (<code>crypto.subtle<\/code>) for HMAC verification.<\/li>\n<li><strong><code>waitUntil<\/code><\/strong>: In a real Workers deployment, use <code>ctx.waitUntil()<\/code> from the <code>ExecutionContext<\/code> to run async processing after the response is sent.<\/li>\n<li><strong>Execution limit<\/strong>: 30 seconds on paid plans, 10ms CPU time on the free plan. The free plan is too restrictive for webhook handlers; use the $5\/month Workers Paid plan.<\/li>\n<li><strong>KV and Durable Objects<\/strong>: Use Cloudflare KV for deduplication storage, or Durable Objects for stateful processing.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Forwarding to a Queue<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">In all three platforms, the recommended pattern is: verify the signature, respond 200, and forward the event to a queue for processing. This keeps the function execution time short and decouples ingestion from processing.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Lambda to SQS<\/h3>\n\n\n\n<pre><code class=\"language-typescript\">import { SQSClient, SendMessageCommand } from &#39;@aws-sdk\/client-sqs&#39;;\n\nconst sqs = new SQSClient({});\n\nasync function processEvent(event: any, rawBody: string) {\n  const hash = crypto.createHash(&#39;sha256&#39;).update(rawBody).digest(&#39;hex&#39;);\n\n  await sqs.send(new SendMessageCommand({\n    QueueUrl: process.env.SQS_QUEUE_URL!,\n    MessageBody: rawBody,\n    MessageDeduplicationId: hash,\n    MessageGroupId: event.event,\n  }));\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Cloud Functions to Pub\/Sub<\/h3>\n\n\n\n<pre><code class=\"language-typescript\">import { PubSub } from &#39;@google-cloud\/pubsub&#39;;\n\nconst pubsub = new PubSub();\nconst topic = pubsub.topic(&#39;webhook-events&#39;);\n\nasync function processEvent(event: any, rawBody: Buffer) {\n  await topic.publishMessage({\n    data: rawBody,\n    attributes: {\n      eventType: event.event,\n    },\n  });\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Workers to Cloudflare Queue<\/h3>\n\n\n\n<pre><code class=\"language-typescript\">async function processEvent(event: any, env: Env, rawBody: string) {\n  await env.WEBHOOK_QUEUE.send({\n    body: rawBody,\n    eventType: event.event,\n  });\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Cost Comparison<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">For 10,000 webhook events per day (a moderate volume):<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table>\n<thead>\n<tr>\n<th>Platform<\/th>\n<th>Monthly Cost<\/th>\n<th>Notes<\/th>\n<\/tr>\n<\/thead>\n<tbody><tr>\n<td>AWS Lambda + API Gateway<\/td>\n<td>~$0.50<\/td>\n<td>Lambda free tier covers 1M requests\/month<\/td>\n<\/tr>\n<tr>\n<td>Google Cloud Functions<\/td>\n<td>~$0.40<\/td>\n<td>2M free invocations\/month<\/td>\n<\/tr>\n<tr>\n<td>Cloudflare Workers<\/td>\n<td>~$0.00<\/td>\n<td>100K free requests\/day covers this<\/td>\n<\/tr>\n<tr>\n<td>Traditional server (smallest instance)<\/td>\n<td>$5-10<\/td>\n<td>Running 24\/7 regardless of traffic<\/td>\n<\/tr>\n<\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">For 1,000,000 events per day:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table>\n<thead>\n<tr>\n<th>Platform<\/th>\n<th>Monthly Cost<\/th>\n<\/tr>\n<\/thead>\n<tbody><tr>\n<td>AWS Lambda + API Gateway<\/td>\n<td>~$15<\/td>\n<\/tr>\n<tr>\n<td>Google Cloud Functions<\/td>\n<td>~$12<\/td>\n<\/tr>\n<tr>\n<td>Cloudflare Workers<\/td>\n<td>~$5<\/td>\n<\/tr>\n<tr>\n<td>Traditional server (scaled)<\/td>\n<td>$20-50<\/td>\n<\/tr>\n<\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Serverless pricing scales linearly with usage, while traditional servers have step-function pricing (you pay for the next tier even if you&#39;re barely over capacity).<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">When NOT to Use Serverless<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Serverless isn&#39;t the right fit when:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>You need long-running processing.<\/strong> Lambda caps at 15 minutes. If your webhook handler triggers a 30-minute batch job, run that on a container or server.<\/li>\n<li><strong>You need persistent connections.<\/strong> WebSocket connections, database connection pools, and in-memory caches don&#39;t work well with ephemeral functions.<\/li>\n<li><strong>Cold starts matter.<\/strong> If your webhook handler must respond in under 50ms consistently, Lambda&#39;s cold starts (100-500ms) are a problem. Workers don&#39;t have this issue.<\/li>\n<li><strong>You already run a server.<\/strong> If you have an Express app handling other API routes, adding a webhook route is simpler than deploying a separate Lambda function.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">For monitoring your serverless webhook handler, see the <a href=\"https:\/\/tolinku.com\/blog\/webhook-delivery-monitoring\/\">delivery monitoring guide<\/a>. For testing before deployment, see the <a href=\"https:\/\/tolinku.com\/blog\/webhook-testing-tools\/\">webhook testing tools guide<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Deploy webhook receivers on AWS Lambda, Google Cloud Functions, and Cloudflare Workers. No servers to manage, auto-scaling, and pay-per-invocation pricing.<\/p>\n","protected":false},"author":2,"featured_media":1176,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Serverless Webhook Handlers for Deep Link Events","rank_math_description":"Deploy webhook receivers on AWS Lambda, Google Cloud Functions, and Cloudflare Workers. No servers to manage with auto-scaling and pay-per-invocation.","rank_math_focus_keyword":"serverless webhook","rank_math_canonical_url":"","rank_math_facebook_title":"","rank_math_facebook_description":"","rank_math_facebook_image":"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/og-serverless-webhook-handlers.png","rank_math_facebook_image_id":"","rank_math_twitter_title":"","rank_math_twitter_description":"","rank_math_twitter_image":"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/og-serverless-webhook-handlers.png","footnotes":""},"categories":[15],"tags":[302,303,20,264,304,301,61],"class_list":["post-1177","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-engineering","tag-aws-lambda","tag-cloud-functions","tag-deep-linking","tag-engineering","tag-infrastructure","tag-serverless","tag-webhooks"],"_links":{"self":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1177","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/comments?post=1177"}],"version-history":[{"count":2,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1177\/revisions"}],"predecessor-version":[{"id":2268,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1177\/revisions\/2268"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media\/1176"}],"wp:attachment":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media?parent=1177"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/categories?post=1177"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/tags?post=1177"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}