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:
- Create a secure checkout session.
- React to the successful payment (webhook).
- Enroll the student in the course.
- Track the payment for revenue reporting.
- 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:
- An authenticated user requests a checkout for a specific course (product).
- Lambda validates the user, retrieves the Polar product ID, and calls the Polar SDK through our secondary adapter.
- Polar returns a checkout URL as it validated that the request was from us based on shared secrets.
- Client redirects the user to Polarβs hosted checkout page.
- User completes payment on Polarβs domain (PCI-compliant, not your problem).
- Polar redirects back to your success/cancel URL with webhook secrets so we can validate it is from them.
- 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)
// 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:
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 | 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:
// 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 |
|---|---|---|
| Payment completed successfully | Enroll user, create payment record |
| Refund processed | Revoke access, update payment status |
| Refund status changes | Log status for audit trail |
| Benefit granted to the customer | Provision access (e.g., GitHub repo invite) |
| Customer links account | Update grant with external account info |
| Benefit revoked (e.g., after refund) | Remove access |
| New subscription started | Activate subscription features |
| Subscription changed (upgrade/downgrade) | Adjust access level |
| 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:
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:
// 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
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)
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:
- Polar fires the webhook β your Lambda handles it.
- Lambda creates records and enrolls the student β immediate access.
- 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
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:
- WAF IP allowlisting (network layer).
- 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.
// 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:
// 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.
// 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:
// 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:
// 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:
- 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.
- Webhooks are the integration backbone β Polar communicates payment outcomes via Standard Webhooks with HMAC signatures. Verify every payload before trusting it.
- 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.
- Hexagonal architecture keeps it clean β the Polar SDK lives in a secondary adapter. Your use cases are provider-agnostic and easily testable.
- Event-driven architecture mirrors payment reality β payments are asynchronous by nature. EDA gives you temporal decoupling, failure isolation, and easy extensibility.
- Secrets belong in Secrets Manager β never in environment variables, never in code. Cache them with
maxAgeto reduce API calls. - Idempotency is non-negotiable β webhooks can arrive more than once. Design every handler to be safely re-executable.
- 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.
