Using callback URLs for approval emails with AWS Step Functions

Guest post by Cloud Robotics Research Scientist at iRobot and AWS Serverless Hero, Ben Kehoe

AWS Step Functions is a serverless workflow orchestration service that lets you coordinate processes using the declarative Amazon States Language. When you have a Step Functions task that takes more than fifteen minutes, you can’t use an AWS Lambda function—Step Functions provides the callback pattern for us in this situation. Approval emails are a common use case in this category.

In this post, I show you how to create a Step Functions state machine that uses the sfn-callback-urls application for an email approval step. The app is available in the AWS Serverless Application Repository. The state machine sends an email containing approve/reject links, and later a confirmation email. You can easily expand this state machine for your use cases.

Solution overview
An approval email must include URLs that send the appropriate result back to Step Functions when the user clicks on them. The URL should be valid for an extended period of time, longer than presigned URLs—what if the user is on vacation this week? Ideally, this doesn’t involve storage of the token and the maintenance that requires. Luckily, there’s an AWS Serverless Application Repository app for that!

The sfn-callback-urls app allows you to generate one-time-use callback URLs through a call to either an Amazon API Gateway or a Lambda function. Each URL has an associated name, whether it causes success or failure, and what output should be sent back to Step Functions. Sending an HTTP GET or POST to a URL sends its output to Step Functions. sfn-callback-urls is stateless, and it also supports POST callbacks with JSON bodies for use with webhooks.

Deploying the app
First, deploy the sfn-callback-urls serverless app and make note of the ARN for the Lambda function that it exposes. In the AWS Serverless Application Repository console, select Show apps that create custom IAM roles or resource policies, and search for sfn-callback-urls.  You can also access the application.

Under application settings, select the box to acknowledge the creation of IAM resources. By default, this app creates a KMS key. You can disable this by setting the DisableEncryption parameter to true, but first read the Security section in the Readme to the left. Scroll down and choose Deploy.

On the deployment confirmation page, choose CreateUrls, which opens the Lambda console for that function. Make note of the function ARN because you need it later.
Create the application by doing the following:

  1. Create an SNS topic and subscribe your email to it.
  2. Create the Lambda function that handles URL creation and email sending, and add proper permissions.
  3. Create an IAM role for the state machine to invoke the Lambda function.
  4. Create the state machine.
  5. Start the execution and send yourself some emails!

Create the SNS topic
In the SNS console, choose Topics, Create Topic. Name the topic ApprovalEmailsTopic, and choose Create Topic.

Make a note of the topic ARN, for example arn:aws:sns:us-east-2:012345678912:ApprovalEmailsTopic.
Now, set up a subscription to receive emails. Choose Create subscription. For Protocol, choose Email, enter an email address, and choose Create subscription.

Wait for an email to arrive in your inbox with a confirmation link. It confirms the subscription, allowing messages published to the topic to be emailed to you.

Create the Lambda function
Now create the Lambda function that handles the creation of callback URLs and sending of emails. For this short post, create a single Lambda function that completes two separate steps:

  • Creating callback URLs
  • Sending the approval email, and later sending the confirmation email

There’s an if statement in the code to separate the two, which requires the state machine to tell the Lambda function which state is invoking it. The best practice here would be to use two separate Lambda functions.

To create the Lambda function in the Lambda console, choose Create function, name it ApprovalEmailsFunction, and select the latest Python 3 runtime. Under Permissions, choose Create a new Role with basic permissions, Create.

Add permissions by scrolling down to Configuration. Choose the link to see the role in the IAM console.

Add IAM permissions
In the IAM console, select the new role and choose Add inline policy. Add permissions for sns:Publish to the topic that you created and lambda:InvokeFunction to the sfn-callback-urls CreateUrls function ARN.

Back in the Lambda console, use the following code in the function:

import json, os, boto3
def lambda_handler(event, context):
    print('Event:', json.dumps(event))
    # Switch between the two blocks of code to run
    # This is normally in separate functions
    if event['step'] == 'SendApprovalRequest':
        print('Calling sfn-callback-urls app')
        input = {
            # Step Functions gives us this callback token
            # sfn-callback-urls needs it to be able to complete the task
            "token": event['token'],
            "actions": [
                # The approval action that transfers the name to the output
                {
                    "name": "approve",
                    "type": "success",
                    "output": {
                        # watch for re-use of this field below
                        "name_in_output": event['name_in_input']
                    }
                },
                # The rejection action that names the rejecter
                {
                    "name": "reject",
                    "type": "failure",
                    "error": "rejected",
                    "cause": event['name_in_input'] + " rejected it"
                }
            ]
        }
        response = boto3.client('lambda').invoke(
            FunctionName=os.environ['CREATE_URLS_FUNCTION'],
            Payload=json.dumps(input)
        )
        urls = json.loads(response['Payload'].read())['urls']
        print('Got urls:', urls)

        # Compose email
        email_subject = 'Step Functions example approval request'

        email_body = """Hello {name},
        Click below (these could be better in HTML emails):

        Approve:
        {approve}

        Reject:
        {reject}
        """.format(
            name=event['name_in_input'],
            approve=urls['approve'],
            reject=urls['reject']
        )
    elif event['step'] == 'SendConfirmation':
        # Compose email
        email_subject = 'Step Functions example complete'

        if 'Error' in event['output']:
            email_body = """Hello,
            Your task was rejected: {cause}
            """.format(
                cause=event['output']['Cause']
            )
        else:
            email_body = """Hello {name},
            Your task is complete.
            """.format(
                name=event['output']['name_in_output']
            )
    else:
        raise ValueError

    print('Sending email:', email_body)
    boto3.client('sns').publish(
        TopicArn=os.environ['TOPIC_ARN'],
        Subject=email_subject,
        Message=email_body
    )
    print('done')
    return {}

Now, set the following environment variables TOPIC_ARN and CREATE_URLS_FUNCTION to the ARNs of your topic and the sfn-callback-urls function noted earlier.

After updating the code and environment variables, choose Save.
 
Create the state machine
You first need a role for the state machine to assume that can invoke the new Lambda function.

In the IAM console, create a role with Step Functions as its trusted entity. This requires the AWSLambdaRole policy, which gives it access to invoke your function. Name the role ApprovalEmailsStateMachineRole.

Now you’re ready to create the state machine. In the Step Functions console, choose Create state machine, name it ApprovalEmails, and use the following code:

{
    "Version": "1.0",
    "StartAt": "SendApprovalRequest",
    "States": {
        "SendApprovalRequest": {
            "Type": "Task",
            "Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken",
            "Parameters": {
                "FunctionName": "ApprovalEmailsFunction",
                "Payload": {
                    "step.$": "$$.State.Name",
                    "name_in_input.$": "$.name",
                    "token.$": "$$.Task.Token"
                }
            },
            "ResultPath": "$.output",
            "Next": "SendConfirmation",
            "Catch": [
                {
                    "ErrorEquals": [ "rejected" ],
                    "ResultPath": "$.output",
                    "Next": "SendConfirmation"
                }
            ]
        },
        "SendConfirmation": {
            "Type": "Task",
            "Resource": "arn:aws:states:::lambda:invoke",
            "Parameters": {
                "FunctionName": "ApprovalEmailsFunction",
                "Payload": {
                    "step.$": "$$.State.Name",
                    "output.$": "$.output"
                }
            },
            "End": true
        }
    }
}

This state machine has two states. It takes as input a JSON object with one field, “name”. Each state is a Lambda task. To shorten this post, I combined the functionality for both states in a single Lambda function. You pass the state name as the step field to allow the function to choose the block of code to run. Using the best practice of using separate functions for different responsibilities, this field would not be necessary.

The first state, SendApprovalRequest, expects an input JSON object with a name field. It packages that name along with the step and the task token (required to complete the callback task), and invokes the Lambda function with it. Whatever output is received as part of the callback, the state machine stores it in the output JSON object under the output field. That output then becomes the input to the second state.

The second state, SendConfirmation, takes that output field along with the step and invokes the function again. The second invocation does not use the callback pattern and doesn’t involve a task token.

Start the execution
To run the example, choose Start execution and set the input to be a JSON object that looks like the following:
{
  "name": "Ben"
}

You see the execution graph with the SendApprovalRequest state highlighted. This means it has started and is waiting for the task token to be returned. Check your inbox for an email with approve and reject links. Choose a link and get a confirmation page in the browser saying that your response has been accepted. In the State Machines console, you see that the execution has finished, and you also receive a confirmation email for approval or rejection.

Conclusion
In this post, I demonstrated how to use the sfn-callback-urls app from the AWS Serverless Application Repository to create URLs for approval emails. I also showed you how to build a system that can create and send those emails and process the results. This pattern can be used as part of a larger state machine to manage your own workflow.

This example is also available as an AWS CloudFormation template in the sfn-callback-urls GitHub repository.

Ben Kehoe