header image
Back to Blog
paymentsawsserverlesspolareda

Integrating Polar Payments with Event-Driven Serverless on AWS

In this article, we're going to walk through how to integrate Polar as a payment provider and merchant of record into a serverless application on AWS, using an event-driven architecture with API Gateway, Lambda, EventBridge, SQS, and CDK. This is relevant for any team building a SaaS platform, online course marketplace, or digital product store that wants to offload payment complexity, tax collection, invoicing, refunds, and compliance to a merchant of record while reacting to payment events

In this article, we’re going to walk through how to integrate Polar as a payment provider and merchant of record into a serverless application on AWS, using an event-driven architecture with API Gateway, Lambda, EventBridge, SQS, and the AWS CDK.

This is relevant for any team building a SaaS platform, online course marketplace, or digital product store that wants to offload payment complexity, tax collection, invoicing, refunds, and compliance to a merchant of record while reacting to payment events in real-time.

β€œThe goal is simple: let Polar handle the messy world of payments, tax, and compliance, while your serverless backend reacts to webhooks in real-time to provision access, track revenue, and delight customers; all without managing payment infrastructure yourself.”

The Problem We’re Solving 🎯

When you’re building a platform that sells digital products internationally, e.g. courses, subscriptions, software licenses, you quickly discover that accepting payments is the easy part. The hard part is everything around it:

  • Tax compliance: VAT, GST, sales tax across dozens of jurisdictions.
  • Invoicing: Generating compliant invoices for every transaction.
  • Refunds: Processing refunds and updating your system state accordingly.
  • Currency handling: Supporting multiple currencies with correct conversion.
  • PCI compliance: Securing card data (or avoiding it entirely).
  • Dispute handling: Managing chargebacks and fraud.

A merchant of record like Polar handles all of this for you. They’re the legal seller. They collect tax, issue invoices, handle refunds, and deal with compliance. You just receive your payout minus their fee.

What is Polar? πŸ’³

Polar is a merchant of record platform designed for developers and digital product creators.

Here’s what Polar handles for you:

Responsibility

You with standard payment providers

Polar (as MoR)

Payment processing

You configure

Polar handles

Tax calculation & collection

You implement (TaxJar, etc.)

Polar handles

Invoice generation

You build

Polar handles

Refund processing

You implement

Polar handles

PCI compliance

Your responsibility

Polar’s responsibility

Currency conversion

You manage

Polar manages

Customer receipts

You send

Polar sends

Regulatory compliance

Your legal team

Polar’s legal team

For a small team running a SaaS or course platform, this is transformative. You go from needing a tax consultant, a billing system, and PCI audit preparation to… calling an API and handling webhooks.

Our Example

We’re building this for an online learning platform where instructors publish courses and students purchase access. When a student buys a course, we need to:

  1. Create a secure checkout session.
  2. React to the successful payment (webhook).
  3. Enroll the student in the course.
  4. Track the payment for revenue reporting.
  5. Handle refunds if needed.

πŸ’‘ Note: All code examples are for discussion only and can be further productionised. They have been made simpler to ease the discussion.

Architecture Overview

The Polar integration has two main flows: outbound (creating checkouts) and inbound (receiving webhooks).

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                        Checkout Flow (Outbound)                              β”‚
β”‚                                                                              β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚  Client   │────▢│ API Gateway │────▢│   Lambda     │────▢│   Polar    β”‚   β”‚
β”‚  β”‚  (Browser)β”‚     β”‚  (JWT Auth) β”‚     β”‚  (Checkout   β”‚     β”‚   API      β”‚   β”‚
β”‚  β”‚           │◀────│             │◀────│   Creator)   │◀────│            β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚       β”‚                                                                      β”‚
β”‚       β”‚  Redirect to Polar hosted checkout (with callback URL and security)  β”‚
β”‚       β–Ό                                                                      β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                                                   β”‚
β”‚  β”‚  Polar Checkout Page  β”‚  ← Hosted by Polar (PCI compliant)                β”‚
β”‚  β”‚  - Card details       β”‚                                                   β”‚
β”‚  β”‚  - Tax calculation    β”‚                                                   β”‚
β”‚  β”‚  - Invoice generation β”‚                                                   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                                   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                        Webhook Flow (Inbound)                                β”‚
β”‚                                                                              β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                     β”‚
β”‚  β”‚   Polar    │────▢│ API Gateway │────▢│   Lambda     β”‚                     β”‚
β”‚  β”‚  Webhooks  β”‚     β”‚  (WAF +     β”‚     β”‚  (Webhook    β”‚                     β”‚
β”‚  β”‚            β”‚     β”‚   IP Allow) β”‚     β”‚   Handler)   β”‚                     β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜                     β”‚
β”‚                                                 β”‚                            β”‚
β”‚                                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”               β”‚
β”‚                                    β–Ό            β–Ό            β–Ό               β”‚
β”‚                          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚                          β”‚  DynamoDB    β”‚ β”‚ EventBridge β”‚ β”‚  Domain      β”‚   β”‚
β”‚                          β”‚  (Enrollment,β”‚ β”‚ (Domain.    β”‚ β”‚  Actions     β”‚   β”‚
β”‚                          β”‚   Payment)   β”‚ β”‚  Events)    β”‚ β”‚  (Provision) β”‚   β”‚
β”‚                          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The key insight: your application never touches card data. Polar’s hosted checkout handles PCI-sensitive operations, and your backend only reacts to confirmed events via webhooks.

Why Polar Works Great with Hexagonal Architecture

If you’re following hexagonal architecture (ports and adapters) in your serverless backend, Polar fits in beautifully. Here’s why:

The payment provider is a secondary adapter. Your use cases don’t know or care that you’re using Polar. They call a port (interface) like createCheckoutSession(). The Polar-specific implementation lives in an adapter that can be swapped without touching business logic. This allows us to swap out for Stripe Managed Service in the future, for example, or other, where we simply have a translation file for mapping to our domain model:

Webhook Request β†’ Primary Adapter (Lambda Handler - zero business logic - deals with inbound event/payload)
                      β”‚
                      β–Ό
               Use Case (Business Logic - calls secondary adapters through repositories)
                      β”‚
                      β–Ό
              Secondary Adapter (Polar SDK - no business logic, just purely framework)

This means:

  • Testing: Mock the payment provider port in unit tests, i.e., no Polar SDK needed.
  • Swappability: If you ever migrate from Polar to another provider, only the adapter changes and a mapping file.
  • Separation of concerns: The use case handles enrollment logic; the adapter handles Polar API calls.
  • Clean boundaries: Webhook validation lives in the adapter, business reactions live in the use case.

Our use cases remain clean, testable, and provider-agnostic. The Polar SDK is an implementation detail, not a dependency of your business logic.

Creating a Secure Checkout

When a user clicks β€œBuy Course”, we need to create a checkout session with Polar and redirect them to the hosted payment page. Let’s walk through how to do this securely.

The Checkout Flow

The checkout flow is straightforward:

  1. An authenticated user requests a checkout for a specific course (product).
  2. Lambda validates the user, retrieves the Polar product ID, and calls the Polar SDK through our secondary adapter.
  3. Polar returns a checkout URL as it validated that the request was from us based on shared secrets.
  4. Client redirects the user to Polar’s hosted checkout page.
  5. User completes payment on Polar’s domain (PCI-compliant, not your problem).
  6. Polar redirects back to your success/cancel URL with webhook secrets so we can validate it is from them.
  7. Polar fires a webhook to confirm the payment server-side (we can consume these domain events/webhooks to do further processing).

The Checkout Adapter (Lambda Handler)

tsx
// Basic example only

import { Polar } from '@polar-sh/sdk';
import { getSecret } from '@aws-lambda-powertools/parameters/secrets';
import { logger } from '@shared/logger';

interface CreateCheckoutRequest {
  productId: string;
  customerId: string;
  customerEmail: string;
  successUrl: string;
  metadata?: Record<string, string>;
}

interface CreateCheckoutResponse {
  checkoutUrl: string;
  checkoutId: string;
}

export async function createCheckoutSession(
  request: CreateCheckoutRequest,
  config: PaymentProviderConfig,
): Promise<CreateCheckoutResponse> {
  logger.info('Creating checkout session', {
    productId: request.productId,
    customerId: request.customerId,
  });

  const polar = new Polar({
    accessToken: config.accessToken, // access token so Polar knows the request is from us
    server: config.apiUrl, // this includes our specific config like client ID etc
  });

  const result = await polar.checkouts.create({
    products: [request.productId], // one or more products
    customerId: request.customerId,
    customerEmail: request.customerEmail,
    successUrl: request.successUrl,
    metadata: request.metadata ?? {}, // we can pass additional metadata to Polar to validate again on inbound
  });

  if (!result.url) {
    throw new Error('Polar did not return a checkout URL');
  }

  logger.info('Checkout session created', {
    checkoutId: result.id,
  });

  return {
    checkoutUrl: result.url,
    checkoutId: result.id,
  };
}

A few things to call out:

βœ”οΈ Server-side creation: The checkout is created server-side, not client-side. This prevents tampering with product IDs or prices.

βœ”οΈ Customer association: We pass the customerId (created in Polar ahead of time) so the purchase is linked to the correct customer record.

βœ”οΈ Metadata: We attach metadata (like courseId, userId) that Polar will echo back in the webhook payload. This is how we correlate the payment to the correct enrollment. We can also add additional security hashes in here, too, to validate on the way back in.

βœ”οΈ No card data: At no point does your Lambda handle card numbers, CVVs, or any PCI-sensitive data. The user enters payment details on Polar’s hosted page. (Ensure that you don't log the inbound webhook event as it would contain details like the customer's address - and you don't want this in your logs!).

Handling Secrets Securely

The Polar access token is the most sensitive credential in this integration. Here’s how to handle it properly:

tsx
import { getSecret } from '@aws-lambda-powertools/parameters/secrets';

// Retrieve from Secrets Manager with caching
const polarAccessToken = await getSecret('your-polar-access-token-path', {
  maxAge: 300, // Cache for 5 minutes β€” reduces API calls
}) as string;

Key principles for secret management:

Practice

Why

Store in Secrets Manager or SSM SecureString

Encrypted at rest with KMS

Never in environment variables

Visible in CloudFormation, console, and logs

Never in code or config files

Committed to git = compromised

Cache with maxAge

Reduces Secrets Manager API calls and cost

Scope IAM to exact ARN

Lambda can only read its own secret

Rotate periodically

Limits blast radius of a leak

In CDK, scope the IAM permission tightly:

tsx
// Example only

const secretArn = `arn:aws:secretsmanager:${region}:${account}:secret:your-polar-access-token-path*`;

lambdaFunction.addToRolePolicy(
  new iam.PolicyStatement({
    effect: iam.Effect.ALLOW,
    actions: ['secretsmanager:GetSecretValue'],
    resources: [secretArn],
  }),
);

The Lambda can read exactly one secret β€” nothing else.

How Polar Webhooks Work πŸͺ

Webhooks are the backbone of the integration. When something happens in Polar (payment completed, refund issued, benefit granted), Polar sends an HTTP POST to your configured endpoint with the event details (they have great documentation to validate against the schema payload).

Webhook Delivery Model

Polar uses the Standard Webhooks specification for signing and delivering events. This means:

  • Every webhook has a cryptographic signature in the headers to validate it came from them.
  • You verify the signature using your webhook secret before trusting the payload.
  • Replay attacks are prevented via timestamp validation.
  • The payload is JSON with a consistent envelope structure.
  • The webhooks come from a limited IP range, so we block any other traffic to that endpoint.

Available Webhook Events

Polar offers a rich set of webhook events. Here are the ones most relevant for a digital product platform:

Event

When It Fires

Typical Action

order.created

Payment completed successfully

Enroll user, create payment record

order.refunded

Refund processed

Revoke access, update payment status

refund.updated

Refund status changes

Log status for audit trail

benefit_grant.created

Benefit granted to the customer

Provision access (e.g., GitHub repo invite)

benefit_grant.updated

Customer links account

Update grant with external account info

benefit_grant.revoked

Benefit revoked (e.g., after refund)

Remove access

subscription.created

New subscription started

Activate subscription features

subscription.updated

Subscription changed (upgrade/downgrade)

Adjust access level

subscription.canceled

Subscription cancelled

Schedule access removal

For a course platform, the critical path is: order.created β†’ enroll student β†’ track payment. Everything else is secondary but important for a complete integration.

Webhook Security: Signature Verification

Never trust a webhook payload without verifying its signature. Polar signs every webhook using HMAC, and you verify it using the standardwebhooks library:

tsx
import { Webhook } from 'standardwebhooks';

export async function verifyWebhookSignature(
  payload: string,
  headers: Record<string, string>,
  webhookSecret: string,
): Promise<boolean> {
  const webhook = new Webhook(webhookSecret);

  // Standard Webhooks uses these specific headers
  const webhookHeaders = {
    'webhook-id': headers['webhook-id'],
    'webhook-timestamp': headers['webhook-timestamp'],
    'webhook-signature': headers['webhook-signature'],
  };

  try {
    // This throws if verification fails
    webhook.verify(payload, webhookHeaders);
    return true;
  } catch (error) {
    logger.error('Webhook signature verification failed', {
      error: error instanceof Error ? error.message : 'Unknown error',
    });
    return false;
  }
}

The standardwebhooks library handles:

  • HMAC verification: Ensures the payload hasn’t been tampered with.
  • Timestamp validation: Rejects webhooks older than 5 minutes (replay protection).
  • Multiple signature support: Polar can rotate secrets without downtime.

Defence in Depth: WAF IP Allowlisting

Signature verification is your primary defence, but we add a second layer with AWS WAF. Polar publishes their webhook source IPs, and we restrict the webhook endpoint to only accept traffic from those specific IPs:

tsx
// Example code - add this to the relevant path for your webhook

// In CDK β€” WAF rule for webhook endpoint
const polarIpSet = new wafv2.CfnIPSet(this, 'PolarWebhookIpSet', {
  scope: 'REGIONAL',
  ipAddressVersion: 'IPV4',
  addresses: [
    '1.2.3.4/32',  // Polar webhook IPs - example only
    // ... other Polar IPs from their documentation
  ],
});

const webAcl = new wafv2.CfnWebACL(this, 'WebhookWaf', {
  scope: 'REGIONAL',
  defaultAction: { block: {} },
  rules: [
    {
      name: 'AllowPolarIPs',
      priority: 1,
      action: { allow: {} },
      statement: {
        ipSetReferenceStatement: {
          arn: polarIpSet.attrArn,
        },
      },
      visibilityConfig: {
        sampledRequestsEnabled: true,
        cloudWatchMetricsEnabled: true,
        metricName: 'PolarWebhookAllowed',
      },
    },
  ],
  visibilityConfig: {
    sampledRequestsEnabled: true,
    cloudWatchMetricsEnabled: true,
    metricName: 'WebhookWafMetric',
  },
});

Now, even if an attacker somehow obtains your webhook secret, they can’t deliver forged webhooks because the request won’t pass the WAF IP check. Two layers of security, both cheap to operate. We personally also validate hashes in the metadata for additional security, as we get these sent back to us in the webhook payload.

The Webhook Handler: Routing Events to Use Cases πŸ”€

Once the webhook is verified, we need to route it to the correct handler based on the event type. This is where the primary adapter pattern shines, i.e. a single Lambda handles all webhook events and delegates to specific handlers.

The Webhook Router

tsx
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { getSecret } from '@aws-lambda-powertools/parameters/secrets';
import { logger, metrics } from '@shared/observability';
import { MetricUnit } from '@aws-lambda-powertools/metrics';

// Event-specific handlers - only some listed for discussion
import { handleOrderCreated } from './handlers/order-created.handler';
import { handleOrderRefunded } from './handlers/order-refunded.handler';
import { handleBenefitGrantCreated } from './handlers/benefit-grant-created.handler';
import { handleBenefitGrantRevoked } from './handlers/benefit-grant-revoked.handler';
// and more...

const SUPPORTED_EVENTS: Record<string, (payload: unknown) => Promise<void>> = {
  'order.created': handleOrderCreated,
  'order.refunded': handleOrderRefunded,
  'benefit_grant.created': handleBenefitGrantCreated,
  'benefit_grant.revoked': handleBenefitGrantRevoked,
  // and more...
};

export async function webhookHandler(
  event: APIGatewayProxyEvent,
): Promise<APIGatewayProxyResult> {
  const body = event.body;

  if (!body) {
    return { statusCode: 400, body: 'Missing request body' };
  }

  // Step 1: Verify webhook signature
  const webhookSecret = await getSecret('your-polar-webhook-secret-path', {
    maxAge: 300,
  }) as string;

  const isValid = await verifyWebhookSignature(
    body,
    event.headers as Record<string, string>,
    webhookSecret,
  );

  if (!isValid) {
    metrics.addMetric('WebhookSignatureInvalid', MetricUnit.Count, 1);
    return { statusCode: 401, body: 'Invalid signature' };
  }

  // Step 2: Parse the event type
  const payload = JSON.parse(body);
  const eventType = payload.type;

  logger.info('Webhook received', { eventType });

  // Step 3: Route to the correct handler based on event type
  const handler = SUPPORTED_EVENTS[eventType];

  if (!handler) {
    logger.warn('Unsupported webhook event type', { eventType });
    // Return 200 β€” we don't want Polar to retry unsupported events
    return { statusCode: 200, body: 'Event type not handled' };
  }

  // Step 4: Execute the handler
  await handler(payload.data);

  metrics.addMetric('WebhookProcessed', MetricUnit.Count, 1);
  metrics.addMetric(`Webhook_${eventType.replace('.', '_')}`, MetricUnit.Count, 1);

  return { statusCode: 200, body: 'OK' };
}

Key design decisions:

βœ”οΈ Return 200 for unsupported events: If Polar sends an event type we don’t handle yet, we acknowledge it. Returning 4xx would cause Polar to retry indefinitely.

βœ”οΈ Verify before parsing: We verify the signature against the raw body string, not the parsed JSON. Parsing first could alter whitespace and break the signature.

βœ”οΈ Metrics per event type: We emit a metric for each event type so we can monitor webhook volume and detect anomalies.

βœ”οΈ Single Lambda, multiple handlers: One entry point keeps infrastructure simple. Each handler is a separate file with a focused responsibility.

Reacting to Inbound Payments πŸ’°

The most important webhook is order.created , this is when money has actually changed hands, and you need to provision access. Let’s walk through how to handle it.

The Order Created Handler (example code only)

tsx
import { z } from 'zod';
import { logger } from '@shared/observability';
import { enrollStudentUseCase } from '@use-cases/enrollment/enroll-student.use-case';
import { createPaymentRecord } from '@repositories/payment.repository';
import { publishEvent } from '@repositories/events-repository';

// Validate the webhook payload with Zod
const orderCreatedSchema = z.object({
  id: z.string(),
  customer_id: z.string(),
  product_id: z.string(),
  amount: z.number(),
  currency: z.string(),
  tax_amount: z.number(),
  net_amount: z.number(),
  created_at: z.string(),
  metadata: z.object({
    userId: z.string(),
    courseId: z.string(),
    // ... and more
  }),
  customer: z.object({
    email: z.string().email(),
    external_id: z.string().optional(),
  }),
});

export async function handleOrderCreated(data: unknown): Promise<void> {
  // Step 1: Validate payload structure
  const parsed = orderCreatedSchema.safeParse(data);

  if (!parsed.success) {
    logger.error('Invalid order.created payload', {
      errors: parsed.error.issues,
    });
    throw new Error('Invalid webhook payload');
  }

  const order = parsed.data;

  logger.info('Processing order.created', {
    orderId: order.id,
    courseId: order.metadata.courseId,
    userId: order.metadata.userId,
  });

  // Step 2: Create payment record in DynamoDB
  await createPaymentRecord({...}); // add your own payload

  // Step 3: Enroll the student in the course
  await enrollStudentUseCase({
    userId: order.metadata.userId,
    courseId: order.metadata.courseId,
    transactionId: order.id,
  });

  // Step 4: Publish domain event for downstream consumers
  await publishEvent('CoursePaymentMade', {
    userId: order.metadata.userId,
    courseId: order.metadata.courseId,
    amount: order.amount / 100,
    currency: order.currency,
    transactionId: order.id,
  });

  logger.info('Order processed successfully', {
    orderId: order.id,
    userId: order.metadata.userId,
  });
}

This is event-driven architecture in action:

  1. Polar fires the webhook β†’ your Lambda handles it.
  2. Lambda creates records and enrolls the student β†’ immediate access.
  3. Lambda publishes a domain event β†’ downstream systems react (notifications, analytics, certificates).

The CoursePaymentMade event can trigger:

  • A Telegram notification to the team (β€œNew purchase!”).
  • A welcome email to the student.
  • Revenue tracking in your reporting system.
  • Certificate pre-generation if the course is short.
  • CRM updated with its own data.

Each downstream consumer is decoupled, and they subscribe to the event independently and can fail without affecting the enrollment flow.

πŸ’‘This is event-driven architecture in action! Each component does one thing, communicates via events, downstream can then consume and do their own priocessing.

You may have noticed we published an event outside of change data capture for this basic code example, but CDC is covered in this great course by James Eastham here to learn more about this pattern: https://www.studyfromexperts.com/courses/serverless-integration-patterns/

CDK Infrastructure: The Payment Service πŸ› οΈ

Let’s look at how the webhook infrastructure is deployed with CDK. The key components are: the API Gateway route, WAF protection, the Lambda function, and the secrets.

The Webhook Lambda

tsx
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as path from 'path';

const webhookHandler = new ProgressiveLambda(this, 'WebhookHandlerLambda', {
  functionName: `${stageName}-webhook-handler`,
  runtime: lambda.Runtime.NODEJS_24_X,
  entry: path.join(
    __dirname,
    '../src/adapters/primary/webhooks/webhook-handler.adapter.ts',
  ),
  handler: 'handler',
  memorySize: 256,
  timeout: cdk.Duration.seconds(10),
  architecture: lambda.Architecture.ARM_64,
  tracing: lambda.Tracing.ACTIVE,
  description: 'Handle webhooks from Polar payment provider',
  environment: {
    STAGE: stageName,
    SERVICE_TABLE_NAME: table.tableName,
    EVENT_BUS_NAME: eventBus.eventBusName,
    // Note: secrets are NOT in environment variables
    // They're retrieved at runtime from Secrets Manager
  },
});

// Least-privilege permissions
table.grantReadWriteData(webhookHandler.lambda);
eventBus.grantPutEventsTo(webhookHandler.lambda);

// Secret access β€” scoped to exact ARNs
webhookHandler.lambda.addToRolePolicy(
  new iam.PolicyStatement({
    effect: iam.Effect.ALLOW,
    actions: ['secretsmanager:GetSecretValue'],
    resources: [
      `arn:aws:secretsmanager:${region}:${account}:secret:${stageName}-polar-webhook-secret*`,
      `arn:aws:secretsmanager:${region}:${account}:secret:${stageName}-polar-access-token*`,
    ],
  }),
);

API Gateway Route

The webhook endpoint is a simple POST route on your API Gateway.

Note: no JWT authorizer on the webhook route. Polar can’t authenticate with your Cognito user pool. Instead, authentication is handled by:

  1. WAF IP allowlisting (network layer).
  2. Webhook signature verification (HMAC application layer validation).

Publishing Domain Events from Webhooks

Once the webhook is processed, we publish domain events to EventBridge. This is where the event-driven architecture really shines, i.e. the webhook handler doesn’t need to know about notifications, analytics, or any other downstream system.

tsx
// example code only

import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge';

const eventBridgeClient = new EventBridgeClient({});

export async function publishEvent(
  detailType: string,
  detail: Record<string, unknown>,
): Promise<void> {
  await eventBridgeClient.send(
    new PutEventsCommand({
      Entries: [
        {
          Source: 'sfe-service',
          DetailType: detailType,
          Detail: JSON.stringify(detail),
          EventBusName: process.env.EVENT_BUS_NAME,
        },
      ],
    }),
  );
}

Downstream consumers subscribe via EventBridge rules:

tsx
// Notification service subscribes to payment events
new events.Rule(this, 'PaymentNotificationRule', {
  eventBus,
  eventPattern: {
    source: ['sfe-service'],
    detailType: ['CoursePaymentMade'],
  },
  targets: [new targets.SqsQueue(notificationQueue)],
});

// Reporting service subscribes to all payment-related events
new events.Rule(this, 'PaymentReportingRule', {
  eventBus,
  eventPattern: {
    source: ['sfe-service'],
    detailType: ['CoursePaymentMade', 'CourseRefunded'], // and more....
  },
  targets: [new targets.SqsQueue(reportingQueue)],
});

This is the power of EDA: the webhook handler publishes one event, and any number of consumers can react independently. Adding a new consumer (say, a Slack notification) requires zero changes to the webhook handler β€” just a new EventBridge rule and a new Lambda.

Security Summary πŸ›‘οΈ

Let’s recap the security layers in this integration:

Layer

Protection

Implementation

Secrets

Polar tokens encrypted at rest

Secrets Manager + KMS

Network

Webhook endpoint restricted to Polar IPs

AWS WAF IP allowlist

Application

Webhook payload integrity verified

Standard Webhooks HMAC

Transport

All communication encrypted in transit

HTTPS/TLS everywhere

IAM

Lambda permissions scoped to exact resources

No wildcard policies

Checkout

Card data never touches your infrastructure

Polar hosted checkout

Authentication

Checkout creation requires valid JWT

API Gateway authorizer

Replay prevention

Webhook timestamps validated

Standard Webhooks spec

Additional Metadata Hashes

Webhook contains hashes in metadata we created to validate against

Application layer security

No single layer is sufficient on its own. Defence in depth means an attacker needs to bypass multiple independent controls to compromise the system.

Why Event-Driven Architecture is Perfect for Payments πŸ”„

Payments are inherently asynchronous. A customer clicks β€œBuy”, but the money doesn’t arrive instantly; there are authorisation holds, fraud checks, bank processing, and settlement windows. Event-driven architecture mirrors this reality:

1. Temporal decoupling: Your checkout Lambda doesn’t wait for the payment to complete. It creates the session and returns immediately. The webhook arrives seconds (or minutes) later when the payment is confirmed.

2. Failure isolation: If your notification service is down, the enrollment still succeeds. If your reporting database is slow, the student still gets access. Each consumer handles events independently.

3. Natural retry semantics: SQS gives you automatic retries with exponential backoff. If the enrollment Lambda fails (maybe DynamoDB is throttled), the message goes back to the queue and is retried. After 3 failures, it moves to the DLQ for investigation.

4. Audit trail for free: EventBridge events are your audit log. Every payment, refund, and enrollment is a timestamped event that can be replayed, queried, or archived.

5. Easy extensibility: Want to add a referral bonus when someone purchases? Add an EventBridge rule. Want to trigger a drip email campaign? Add another rule. Zero changes to existing code.

                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚  CoursePaymentMade  β”‚
                    β”‚  (Domain Event)     β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                               β”‚
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β–Ό                β–Ό                β–Ό
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚ Notification β”‚  β”‚  Reporting   β”‚  β”‚  Future      β”‚
    β”‚ Service      β”‚  β”‚  Service     β”‚  β”‚  Consumer    β”‚
    β”‚ (Telegram)   β”‚  β”‚  (DSQL)      β”‚  β”‚  (???)       β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Each consumer is independently deployable (nested stacks), independently scalable, and independently testable. This is the power of loose coupling through events.

Gotchas and Tips

Before we wrap up, here are some things that will save you time when integrating with Polar:

1. Amounts Are in Cents

Polar (like Stripe) sends monetary amounts in the smallest currency unit. $29.99 arrives as 2999. Always divide by 100 when storing for display, and multiply by 100 when sending to the API.

tsx
// From webhook β†’ your database
const displayAmount = order.amount / 100; // 2999 β†’ 29.99

// From your UI β†’ Polar API
const polarAmount = userInput * 100; // 29.99 β†’ 2999

2. Idempotency is Critical

Webhooks can be delivered more than once. Polar guarantees at-least-once delivery, not exactly-once. Your handlers must be idempotent:

tsx
// Example code

// Check if we've already processed this order
const existingPayment = await getPaymentByTransactionId(order.id);

if (existingPayment) {
  logger.info('Order already processed, skipping', { orderId: order.id });
  return; // Idempotent β€” safe to return without error
}

3. Return 200 Quickly

Polar expects a 200 response within a reasonable timeout. If your handler takes too long, Polar will retry, potentially causing duplicate processing.

Keep webhook handlers fast:

  • Validate and acknowledge quickly.
  • Offload heavy work to SQS/EventBridge for async processing.
  • Don’t call slow external APIs synchronously in the webhook handler.

4. Use Metadata for Correlation

Always attach your internal IDs (userId, courseId) as metadata when creating checkouts. Polar echoes this metadata back in webhooks, saving you from needing to look up the mapping:

tsx
// When creating checkout
metadata: { userId: 'abc-123', courseId: 'def-456' }

// In the webhook payload
order.metadata.userId  // 'abc-123'
order.metadata.courseId // 'def-456'

Wrapping Up πŸ“

We’ve covered a lot of ground in this article:

The key takeaways:

  1. Polar, as merchant of record, eliminates payment complexity β€” tax, invoicing, PCI compliance, and refund processing are all handled for you. You focus on your product, not payment infrastructure.
  2. Webhooks are the integration backbone β€” Polar communicates payment outcomes via Standard Webhooks with HMAC signatures. Verify every payload before trusting it.
  3. Defence in depth for webhook security β€” WAF IP allowlisting at the network layer, signature verification at the application layer, and scoped IAM permissions at the infrastructure layer.
  4. Hexagonal architecture keeps it clean β€” the Polar SDK lives in a secondary adapter. Your use cases are provider-agnostic and easily testable.
  5. Event-driven architecture mirrors payment reality β€” payments are asynchronous by nature. EDA gives you temporal decoupling, failure isolation, and easy extensibility.
  6. Secrets belong in Secrets Manager β€” never in environment variables, never in code. Cache them with maxAge to reduce API calls.
  7. Idempotency is non-negotiable β€” webhooks can arrive more than once. Design every handler to be safely re-executable.
  8. Domain events enable loose coupling β€” the webhook handler publishes events, and any number of downstream consumers can react independently without modifying the payment flow.

Whether you’re building a course platform, a SaaS product, or a digital marketplace, the pattern is the same: let the merchant of record handle the payment complexity, verify webhooks cryptographically, react with event-driven architecture, and keep your business logic clean with hexagonal boundaries.

I hope you found this article useful. If you have any questions or feedback, feel free to reach out!

Ready to level up your AWS skills?

Visit sign-up today and join a community of builders and architects dedicated to mastering the cloud.

Study From Experts

Connect With Us

Β© 2026 Study From Experts

All rights reserved