Skip to content

Set up webhooks in the Nextmv Platform

⌛️ Approximate time to complete: 10 min.

In this tutorial you will learn how to set up webhooks using the Nextmv Cloud API and Nextmv CLI. Webhooks are a mechanism for the Nextmv platform to send real-time notifications to your application when certain events occur. Complete this tutorial if you:

  • Want to learn how webhooks work in the Nextmv Platform.
  • Are fluent using Python 🐍.
  • Are familiar using uv for managing Python.

To complete this tutorial we will use ngrok to route traffic from the public internet to your local machine. If you already have a server that can receive webhooks, you can use it instead of ngrok.

At a high level, this tutorial will go through the following steps:

  1. Set up ngrok.
  2. Create and set up a webhook in the Nextmv Platform.
  3. Set up a local server to receive webhooks.
  4. Test the webhook by triggering an event in the Nextmv Platform.

You may follow along with the full tutorial code. Let’s dive right in 🤿.

1. Set up ngrok

Head to ngrok and sign up for a free account. Once you have signed up, you will be prompted to download and install ngrok. After you are done, go ahead and get a public URL for your app. Ngrok will give you a command that you can run to get a public URL. Please note that this command is customized to use port 8000. For example:

ngrok http 8000
ngrok

Request early access to new features: https://dashboard.ngrok.com/early-access

Session Status                online
Account                       Your name (Plan: Free)
Version                       3.39.9
Region                        United States (California) (us-cal-1)
Web Interface                 http://127.0.0.1:4040
Forwarding                    https://urgency-backup-delta.ngrok-free.dev -> http://localhost:8000

Connections                   ttl     opn     rt1     rt5     p50     p90
                              0       0       0.00    0.00    0.00    0.00

As you can see, the public URL is https://urgency-backup-delta.ngrok-free.dev. It will redirect traffic to your local machine on port 8000.

Tip

Don't close this terminal window, keep it running so that the tunnel remains active.

2. Create an account

The full suite of benefits starts with a Nextmv Cloud account.

  1. Visit the Nextmv Console to sign up for an account at https://cloud.nextmv.io.
  2. Fill out the form. A member of the Nextmv team will reach out to you to complete the sign-up process.
  3. Log in to your account. The Nextmv Console is ready to use!

Once you have logged in to your account, you need to fetch your API key. You can do so from your settings.

API keys

When you have your API key, it is convenient to save it as an environment variable so that you can use it for the rest of this tutorial.

export NEXTMV_API_KEY="<YOUR-API-KEY>"
$env:NEXTMV_API_KEY = "<YOUR-API-KEY>"

3. Create a webhook

We are going to create a webhook that subscribes to events of type run.status. This event is triggered when the status of a run changes. For example, from running to succeeded.

Use curl to create a webhook. Please note that we are using the following endpoint URL for the endpoint_url property: https://urgency-backup-delta.ngrok-free.dev/webhookhandler. This path corresponds to:

  • The public URL that ngrok has given you.
  • The /webhookhandler path that we will set up in the next steps to handle incoming webhook requests.
curl -X 'POST' \
  'https://api.cloud.nextmv.io/v1/webhooks' \
  -H 'accept: */*' \
  -H "Authorization: Bearer $NEXTMV_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "id": "test_webhook_handler",
  "event_types": [
    "run.status"
  ],
  "endpoint_url": "https://urgency-backup-delta.ngrok-free.dev/webhookhandler",
  "description": "Test webhook"
}'
{
  "id": "test_webhook_handler"
}

You may use any id you want for the webhook.

4. Retrieve the secret for the webhook

Retrieve the secret for the webhook. The secret is used to recompute the HMAC signature of the request to verify its authenticity.

curl -X 'GET' \
  'https://api.cloud.nextmv.io/v1/webhooks/test_webhook_handler/secret' \
  -H 'accept: application/json' \
  -H "Authorization: Bearer $NEXTMV_API_KEY"
{
  "id": "test_webhook_handler",
  "secret": "YOUR_WEBHOOK_SECRET"
}

We are going to use the value of the secret property in the next steps to verify the authenticity of the webhook requests.

5. Create a local server to receive webhooks

Info

If you already have a server that can receive webhooks, you can use it instead of following this step.

If you are following along with the full tutorial code, run the following command in the root of your project.

uv sync

On the other hand, if you are completing this tutorial from scratch, run the following command in the root of your project.

uv init --bare # Execute if you don't have a pyproject.toml file in your project.
uv add 'fastapi[standard]'

This example will use FastAPI to create a simple server that will handle incoming webhook requests.

Create a file named main.py (if it does not exist) and add the following code to it:

main.py
import hashlib
import hmac
import time

from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse

SECRET = "YOUR_WEBHOOK_SECRET"  # Replace with your actual webhook secret

app = FastAPI()


def check_signature(payload: bytes, t: int, signature: str, secret: str):
    """
    Recompute the signature of the payload using the secret and the timestamp. Compare the
    recomputed signature with the signature provided in the header.
    """
    mac = hmac.new(secret, digestmod=hashlib.sha256)
    mac.update(str(t).encode())
    mac.update(b".")
    mac.update(payload)
    recomputed_signature = mac.hexdigest()
    return recomputed_signature == signature


@app.post("/webhookhandler")
async def webhook_handler(request: Request):
    """
    Handle incoming webhook requests.
    """

    # Gets the signature from the request header.
    signature = request.headers.get("nextmv-signature")

    # Extract timestamp and signature from header. Check the timestamp and
    # extract the signature string.
    signature_time, signature_string = 0, ""
    try:
        t, sig = signature.split(",")
        now = time.time()
        signature_time = int(t.split("=")[1])
        signature_string = sig.split("=")[1]

        # Check if the timestamp is not older than 5 minutes and not in the
        # future. This means to avoid replay attacks.
        if not (now - 300 < signature_time < now + 60):
            print(f"Invalid Time Value: {now - 300} < {signature_time} < {now + 60}")
            return Response(
                status_code=401,
                headers={"content-type": "text/plain"},
                content=bytes("Unauthorized: Invalid Time Value", "utf-8"),
            )
    except Exception:
        return Response(
            status_code=401,
            headers={"content-type": "text/plain"},
            content=bytes("Unauthorized: Invalid Signature Format", "utf-8"),
        )

    # Make sure secret is set.
    if not SECRET:
        return Response(
            status_code=500,
            headers={"content-type": "text/plain"},
            content=bytes("Internal Server Error", "utf-8"),
        )

    # Check signature.
    body = await request.body()
    if not check_signature(body, signature_time, signature_string, SECRET.encode()):
        return Response(
            status_code=401,
            headers={"content-type": "text/plain"},
            content=bytes("Unauthorized: Invalid signature", "utf-8"),
        )

    # Do something useful with the webhook payload here. For example, you can
    # parse the JSON payload and take action based on its contents.
    body_json = body.decode("utf-8")
    print(body_json)

    # Optionally give a response to the webhook sender, which is the Nextmv
    # Cloud API.
    return JSONResponse(
        status_code=200,
        content={
            "message": "Webhook received!",
            "body": body_json,
        },
    )

Note that the value of the SECRET constant should be the secret value that you retrieved in the previous step.

This script:

  1. Creates a FastAPI server that listens for incoming POST requests at the /webhookhandler endpoint.
  2. Extracts the Nextmv-Signature header from the request.
  3. Validates the timestamp and signature to ensure the request is authentic.
  4. Processes the webhook request and returns a JSON response.

Now, run the server with the following command, noting that we are using port 8000 to match the ngrok setup:

uv run fastapi dev --port 8000
⚡️ Starting FastAPI in development mode

🐍 Using import string: main:app (auto-discovered, use --verbose to learn more)

💡 You can configure an entrypoint in pyproject.toml for this app with:

    [tool.fastapi]
    entrypoint = "main:app"

🌐 Server started at http://127.0.0.1:8000
    Documentation at http://127.0.0.1:8000/docs

  Logs:

  Will watch for changes in these directories: ['/your/project/directory']
  Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
  Started reloader process [62721] using WatchFiles
  Started server process [62749]
  Waiting for application startup.
  Application startup complete.

Tip

Do not close this terminal window, keep it running so that the server remains active. At this point, you should have 2 terminal windows open:

  1. The first one running ngrok.
  2. The second one running the FastAPI server.

We have everything set up to start testing the webhook. In the next step, we will trigger an event in the Nextmv Platform to test the webhook.

6. Install the Nextmv CLI

Please see the Nextmv CLI installation guide.

7. Clone a community app

To work with community apps you have two options:

  1. Clone the GitHub repository locally.
  2. Use the Nextmv CLI to clone a specific community app.

This tutorial will use the second option.

For this tutorial, we will be using the python-hello-world community app, which is a "hello world" style application in Python. To clone this community app, run the following command in a new terminal (keeping the terminals for ngrok and FastAPI open):

nextmv community clone -a python-hello-world
 Successfully cloned the python-hello-world community app, using version latest in path: python-hello-world.
 Registered the cloned community app python-hello-world as a local app with ID local-app-xxxx.

This command is saved as app1.sh in the full tutorial code.

Once the app has been cloned, you should see a structure similar to the following:

.
├── main.py
├── pyproject.toml
├── python-hello-world
   ├── app.yaml
   ├── input.json
   ├── LICENSE
   ├── main.py
   ├── pyproject.toml
   ├── README.md
   ├── requirements.txt
   └── uv.lock
├── README.md
└── uv.lock

8. Create your Nextmv Cloud application

Tip

Go to the Applications section to learn more about Nextmv applications.

Run the following command:

nextmv cloud app create -a test-webhooks --exist-ok
 Creating or getting application...
{
  "id": "test-webhooks",
  "name": "test-webhooks",
  "description": "",
  "type": "custom",
  "default_instance": "latest",
  "default_experiment_instance": "",
  "subscription_id": "",
  "locked": false,
  "created_at": "2026-07-09T18:40:36.158310Z",
  "updated_at": "2026-07-09T18:42:58.375241Z"
}

This will create a new application in Nextmv Cloud. Note that the name and app ID can be different, but for simplicity this tutorial uses the same name and app ID. This command is saved as app2.sh in the full tutorial code. You can also create applications directly from Nextmv Console.

You can go to the Apps section in the Nextmv Console where you will see your applications.

Apps

9. Push your Nextmv application

You are going to push your app to Nextmv Cloud. Once an application has been pushed, you can run it remotely, perform testing, experimentation, and much more. Pushing is the equivalent of deploying an application, this is, taking the executable code and sending it to Nextmv Cloud.

Deploy your app (push it) to Nextmv Cloud. Note that this command is being executed outside (one directory above) of the python-hello-world directory.

nextmv cloud app push -a test-webhooks --app-dir ./python-hello-world
💽 Starting build for Nextmv application.
🐍 Bundling Python dependencies.
📋 Copied files listed in "app.yaml" manifest.
📦 Packaged application (25.49 MiB, 2120 files).
🌟 Pushing to application: "test-webhooks".
💥️ Successfully pushed to application: "test-webhooks".
{
  "app_id": "test-webhooks",
  "endpoint": "api.cloud.nextmv.io",
  "instance_url": "https://api.cloud.nextmv.io/v1/applications/test-webhooks/runs?instance_id=latest"
}

This command is saved as app3.sh in the full tutorial code.

You can go to the Apps section in the Nextmv Console where you will see your application. You can click on it to see more details. Once you are in the overview of the application in the Nextmv Console, it should show the following:

Pushed app

  • There is now a pushed executable.
  • There is an auto-created latest instance, assigned to the executable.

An instance is like the endpoint of the application.

10. Test the webhook

We are going to test the webhook by running the Nextmv application remotely. Here is the order of operations that will happen:

  1. We start a remote run of the Nextmv application.
  2. A webhook request is sent to the ngrok public URL. The run.status event is triggered with the run status being queued.
  3. The ngrok public URL redirects the request to the local server.
  4. Our local server receives and processes the webhook request.
  5. When the run status changes to running, another webhook request is sent, repeating steps 3 and 4.
  6. When the run status changes to succeeded, another webhook request is sent, repeating steps 3 and 4.

In total, we will receive 3 webhook requests, one for each run status change.

You can run your Nextmv application using the Nextmv CLI. Here is an example command you can run from the root of the app.

nextmv cloud run create -a test-webhooks --input ./python-hello-world/input.json --wait
 Run latest-GztfcZEDR created.
 Getting run results...
💡 Removed assets from output for cleaner display, use --output to save the full output.
{
  "description": "",
  "id": "latest-GztfcZEDR",
  "metadata": {
    "application_id": "test-webhooks",
    "application_instance_id": "latest",
    "application_version_id": "",
    "created_at": "2026-07-20T20:44:40Z",
    "duration": 4566.0,
    "error": "",
    "execution_class": "6c9500mb870s",
    "execution_duration": 4130.0,
    "format": {
      "input": {
        "type": "json"
      },
      "output": {
        "type": "json"
      }
    },
    "initiated_at": "2026-07-20T20:44:40.778332Z",
    "input_size": 61.0,
    "metrics": {
      "message": "Hello, world",
      "value": 1.23
    },
    "options": {
      "active_options": {
        "details": "true"
      },
      "options_summary": [
        {
          "name": "details",
          "source": "version",
          "value": "true"
        }
      ]
    },
    "output_size": 25099.0,
    "queuing_disabled": false,
    "queuing_priority": 6,
    "run_type": {
      "type": "standard",
      "definition_id": "",
      "reference_id": ""
    },
    "runtime": "python-3_11",
    "status_v2": "succeeded"
  },
  "name": "",
  "user_email": "sebastian@nextmv.io",
  "console_url": "https://cloud.nextmv.io/app/test-webhooks/run/latest-GztfcZEDR?view=details",
  "output": {
    "options": {
      "details": true
    },
    "solution": {
      "message": "Hello, world"
    },
    "metrics": {
      "value": 1.23,
      "message": "Hello, world"
    }
  }
}

This command is saved as app4.sh in the full tutorial code.

As soon as you run the command, you should see the two other terminal windows (ngrok and FastAPI server) receiving and processing the webhook requests. You should see output similar to the following:

HTTP Requests
-------------

16:03:13.416 -13 POST /webhookhandler           200 OK
16:03:09.296 -09 POST /webhookhandler           200 OK
16:03:09.747 -09 POST /webhookhandler           200 OK
{"event_type":"run.status","api_version":"2024-03-04","data":{"run_id":"latest-AZSEtZEDg","status":"running","status_v2":"queued","created_at":"2026-07-20T21:03:08Z","duration":0,"input_size":61,"output_size":0,"error":null,"application_id":"test-webhooks","application_instance_id":"latest","application_version_id":""}}
  18.189.36.125:0 - "POST /webhookhandler HTTP/1.1" 200
{"event_type":"run.status","api_version":"2024-03-04","data":{"run_id":"latest-AZSEtZEDg","status":"running","status_v2":"running","created_at":"2026-07-20T21:03:08Z","duration":0,"input_size":61,"output_size":0,"error":null,"application_id":"test-webhooks","application_instance_id":"latest","application_version_id":""}}
  18.189.36.125:0 - "POST /webhookhandler HTTP/1.1" 200
{"event_type":"run.status","api_version":"2024-03-04","data":{"run_id":"latest-AZSEtZEDg","status":"succeeded","status_v2":"succeeded","created_at":"2026-07-20T21:03:08Z","duration":4091,"input_size":61,"output_size":25099,"error":null,"application_id":"test-webhooks","application_instance_id":"latest","application_version_id":""}}
  18.189.36.125:0 - "POST /webhookhandler HTTP/1.1" 200

You can inspect the conversations of the webhook. A conversation is the request to and the response from the registered endpoint.

curl -X 'GET' \
  'https://api.cloud.nextmv.io/v1/webhooks/test_webhook_handler/conversations' \
  -H 'accept: application/json' \
  -H "Authorization: Bearer $NEXTMV_API_KEY"
{
  "items": [
    {
      "id": "conv-3GmeuPi9bmEP8Og3ahlmOAIq2lv",
      "webhook_id": "test_webhook_handler",
      "created_at": "2026-07-20T21:03:13Z",
      "http_status": 200,
      "response_duration": null,
      "error": null,
      "status": "succeeded"
    },
    {
      "id": "conv-3Gmeu7bkkikvo8ePIPfYxwCTcNI",
      "webhook_id": "test_webhook_handler",
      "created_at": "2026-07-20T21:03:10Z",
      "http_status": 200,
      "response_duration": null,
      "error": null,
      "status": "succeeded"
    },
    {
      "id": "conv-3GmetxBlkrbkckV3rihq1NRgQa8",
      "webhook_id": "test_webhook_handler",
      "created_at": "2026-07-20T21:03:09Z",
      "http_status": 200,
      "response_duration": null,
      "error": null,
      "status": "succeeded"
    }
  ]
}

If we inspect the most recent conversation, we can see the request and response that was processed by the webhook.

curl -X 'GET' \
  'https://api.cloud.nextmv.io/v1/webhooks/test_webhook_handler/conversations/conv-3GmeuPi9bmEP8Og3ahlmOAIq2lv' \
  -H 'accept: application/json' \
  -H "Authorization: Bearer $NEXTMV_API_KEY"
{
  "id": "conv-3GmeuPi9bmEP8Og3ahlmOAIq2lv",
  "webhook_id": "test_webhook_handler",
  "created_at": "2026-07-20T21:03:13Z",
  "http_status": 200,
  "response_duration": 175,
  "endpoint_url": "https://urgency-backup-delta.ngrok-free.dev/webhookhandler",
  "request_body": {
    "event_type": "run.status",
    "api_version": "2024-03-04",
    "data": {
      "run_id": "latest-AZSEtZEDg",
      "status": "succeeded",
      "status_v2": "succeeded",
      "created_at": "2026-07-20T21:03:08Z",
      "duration": 4091,
      "input_size": 61,
      "output_size": 25099,
      "error": null,
      "application_id": "test-webhooks",
      "application_instance_id": "latest",
      "application_version_id": ""
    }
  },
  "response_body": "{\"message\":\"Webhook received!\",\"body\":\"{\\\"event_type\\\":\\\"run.status\\\",\\\"api_version\\\":\\\"2024-03-04\\\",\\\"data\\\":{\\\"run_id\\\":\\\"latest-AZSEtZEDg\\\",\\\"status\\\":\\\"succeeded\\\",\\\"status_v2\\\":\\\"succeeded\\\",\\\"created_at\\\":\\\"2026-07-20T21:03:08Z\\\",\\\"duration\\\":4091,\\\"input_size\\\":61,\\\"output_size\\\":25099,\\\"error\\\":null,\\\"application_id\\\":\\\"test-webhooks\\\",\\\"application_instance_id\\\":\\\"latest\\\",\\\"application_version_id\\\":\\\"\\\"}}\"}",
  "error": null,
  "status": "succeeded"
}

🎉🎉🎉 Congratulations, you have finished this tutorial!

Full tutorial code

You can find the consolidated code examples used in this tutorial in the tutorials GitHub repository. The webhooks dir contains all the code that was shown in this tutorial.

Go into the directory for instructions about running the decision model.