Skip to content

adding nodejs webhook df pattern#2967

Open
ginjups wants to merge 2 commits intoaws-samples:mainfrom
ginjups:ginjups-df-webhook-nodejs-sam
Open

adding nodejs webhook df pattern#2967
ginjups wants to merge 2 commits intoaws-samples:mainfrom
ginjups:ginjups-df-webhook-nodejs-sam

Conversation

@ginjups
Copy link
Contributor

@ginjups ginjups commented Feb 19, 2026

Issue #, if available:

Description of changes:

adding nodejs webhook df pattern

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@bfreiberg bfreiberg added the durable functions Pattern for AWS Lambda durable functions label Mar 2, 2026
Copy link
Contributor

@marcojahn marcojahn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ginjups thank you for you contribution, I've created a bunch of comments, could you please verify?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please align your README.md with the one listed here: https://github.com/aws-samples/serverless-patterns/tree/main/_pattern-model

  • Order of items
  • How It Works, Webhook Processing Workflow (3 Steps), Key Features can be condensed in one item
  • Required IAM Permissions - can be removed
  • Components section can be removed entirely
  • API Endpoints, Monitoring, Error Handling, Cost Optimization, Security Considerations, NodeJS Implementation Notes can be removed
  • Learn More can be integrated into the first description using Links to the docs
  • The README does not mention that deployed resources may incur AWS charges. While the pattern uses pay-per-use services, a cost warning helps users understand they should clean up after testing.
  • The README does not mention that deployed resources may incur AWS charges. While the pattern uses pay-per-use services, a cost warning helps users understand they should clean up after testing.

If you like, you

  • can add numbers to your architecture diagram and move/re-use content blocks (please as short/crisp as possible) to the architecture diagram (below) in a written form.

sam build
```

2. **Deploy to AWS**:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please note required inputs WebhookSecret

- `WebhookApiUrl`: Use this for sending webhook POST requests
- `StatusQueryApiUrl`: Use this for querying execution status

3. **Test the webhook**:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
3. **Test the webhook**:
## Testing

Own section please

curl <StatusQueryApiUrl>
```

**Success indicators:**
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please ad a way testing the failure handling & timeout (set to a low limit) as it is described as a key capability of the sample

Comment on lines +69 to +73
- Effect: Allow
Action:
- lambda:CheckpointDurableExecutions
- lambda:GetDurableExecutionState
Resource: !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${Environment}-webhook-processor'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- Effect: Allow
Action:
- lambda:CheckpointDurableExecutions
- lambda:GetDurableExecutionState
Resource: !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${Environment}-webhook-processor'
- AWSLambdaBasicExecutionRole
- AWSLambdaBasicDurableExecutionRolePolicy

Should be fine to be replaced with managed policies

}));

// Call the separate webhook validator function
const { LambdaClient, InvokeCommand } = await import('@aws-sdk/client-lambda');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues here:

Dynamic import inside a step: The await import() is an async operation that adds latency on every first execution of this step. If the step retries, the import runs again. SDK clients should be initialized in the module scope (outside the handler) for connection reuse across warm invocations.

New client on every step execution: Creating a new LambdaClient inside the step means no HTTP connection reuse. The DynamoDB client is correctly initialized at module scope (lines 7-8), but the Lambda client is not.

// ✅ FIX — Move to module scope alongside DynamoDB client
import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';

const lambdaClient = new LambdaClient({});

Comment on lines +68 to +75
const validatorFunctionArn = process.env.WEBHOOK_VALIDATOR_FUNCTION_ARN;
const invokeResponse = await lambdaClient.send(new InvokeCommand({
FunctionName: validatorFunctionArn,
Payload: JSON.stringify({
payload: webhookPayload,
executionToken: executionToken
})
}));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The durable execution SDK provides context.invoke() specifically for calling other Lambda functions from within a durable function. Using context.invoke() instead of manual SDK invocation provides:

Automatic checkpointing of the invocation result
Built-in retry handling
Proper integration with the durable execution lifecycle
The current approach wraps a manual Lambda invocation inside a step, which works but misses the SDK's built-in invocation support.

// ✅ FIX — Use context.invoke() for Lambda-to-Lambda calls
const validatorResult = await context.invoke(validatorFunctionArn, {
    payload: webhookPayload,
    executionToken: executionToken
});

Comment on lines +199 to +215
} catch (error) {
console.error(`Error processing webhook ${executionToken}:`, error.message);

// Update error state
await dynamodb.send(new UpdateCommand({
TableName: eventsTableName,
Key: { executionToken },
UpdateExpression: 'SET #status = :status, #error = :error',
ExpressionAttributeNames: {
'#status': 'status',
'#error': 'error'
},
ExpressionAttributeValues: {
':status': 'FAILED',
':error': error.message
}
}));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch block is outside any step. If an error occurs and the function retries, the catch block's DynamoDB write runs on every retry attempt. More critically, if the error is transient and the function eventually succeeds on retry, the catch block from the failed attempt has already written FAILED status to DynamoDB — but the successful retry overwrites it. This creates a race condition in the status visible to the query API.

The durable SDK handles retries automatically for steps. The outer try/catch should only handle truly unrecoverable errors, and the error state write should be wrapped in a step.

// ✅ FIX — Let the SDK handle retries; wrap error handling in a step
} catch (error) {
    const errorResult = await context.step('handle-error', async () => {
        await dynamodb.send(new UpdateCommand({
            TableName: eventsTableName,
            Key: { executionToken },
            UpdateExpression: 'SET #status = :status, #error = :error',
            ExpressionAttributeNames: { '#status': 'status', '#error': 'error' },
            ExpressionAttributeValues: { ':status': 'FAILED', ':error': error.message }
        }));
        return { status: 'FAILED', error: error.message };
    });
    
    return {
        statusCode: 500,
        body: JSON.stringify({ message: 'Webhook processing failed', ...errorResult })
    };
}

Comment on lines +141 to +153
return {
executionToken: executionToken,
status: "processed",
originalPayload: webhookPayload,
businessResult: `Processed webhook of type: ${webhookPayload.type || 'unknown'}`,
dataTransformed: webhookPayload.data ? JSON.stringify(webhookPayload.data).toUpperCase() : null,
processedAt: new Date().toISOString(),
metadata: {
processedBy: 'webhook-processor-nodejs',
version: '1.0.0'
}
};
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return {
executionToken: executionToken,
status: "processed",
originalPayload: webhookPayload,
businessResult: `Processed webhook of type: ${webhookPayload.type || 'unknown'}`,
dataTransformed: webhookPayload.data ? JSON.stringify(webhookPayload.data).toUpperCase() : null,
processedAt: new Date().toISOString(),
metadata: {
processedBy: 'webhook-processor-nodejs',
version: '1.0.0'
}
};
});
return {
executionToken,
status: "processed",
payloadType: webhookPayload.type || 'unknown',
processedAt: new Date().toISOString()
};

AWS best practices state:

"Keep state minimal — Store IDs and references, not full objects. [...] Use Amazon S3 or DynamoDB for large data, pass references in state."

The originalPayload is already stored in DynamoDB (from the initial PutCommand). Including it in the step return value means it's also serialized into the checkpoint. For large webhook payloads, this doubles storage and increases checkpoint costs. The processingResult is then also embedded in Step 3's DynamoDB write and return value, tripling the data.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please review

Each step writes a status update to DynamoDB before doing its main work. If a step retries, the DynamoDB UpdateCommand runs again. While DynamoDB UpdateCommand is naturally idempotent for these status writes (writing the same value is a no-op), the pattern should be documented as intentionally idempotent.

More importantly, if Step 1 completes but Step 2 fails and the function replays, Step 1's DynamoDB write does NOT re-execute (it's checkpointed), but the DynamoDB record still shows the status from Step 1's original execution. This is correct behavior, but the pattern should be aware that the DynamoDB status reflects the last successfully written status, not necessarily the current replay position.

No code change needed, but this should be documented in the README as a design consideration.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

durable functions Pattern for AWS Lambda durable functions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants