Python API Integrations In-Depth Tutorial
Build a Python service that orchestrates two remote systems, secure it, expose it over REST, schedule it, test it and take its configuration to git - in about an hour and a half.
In this tutorial you will build a working API integration service in Python that orchestrates two remote systems, secure it with an API key, expose it as a REST endpoint and schedule it for background execution. The tutorial takes about an hour and a half.
Here's what you'll have at the end:
- A complete, working environment that you can use for development, testing, and production.
- A reusable integration service that orchestrates and integrates remote APIs.
- A REST API channel for external API clients to invoke your service.
- A job scheduled in the platform's scheduler to invoke the service periodically.
- API documentation generated for your service, with API Management that you didn't have to configure.
- Unit tests for the service and its whole configuration in a file that you can keep in git.
Getting started
First, let's install the platform, take a quick look at its architecture and invoke your first service from the built-in IDE.
Installing Zato
The recommended way to install Zato is via Docker. Both Desktop and Docker command line can be used.
Our quick-start image will auto-create an entire environment and various pieces of configuration for you, all set up and ready to work, and all in under 5 minutes. It includes preinstalled demo config - REST APIs and HL7 MLLP with FHIR among them.
You use the same Docker image for development, testing and production, so it's a great time saver that helps you focus on things that are immediately useful, like the actual API integrations.
So, go to the Docker installation page, select Docker Desktop if you're on Windows or Mac, or select Docker command line if you're on Linux, fill out the details and we can resume the tutorial once you're back.
Zato architecture
- There are one or more servers running in an environment. Servers are where your services are deployed.
- You configure your services and integrations in the browser-based Dashboard.
- Background jobs run in the built-in scheduler. You create the jobs in the Dashboard, the scheduler keeps track of which services to invoke when, and then it invokes them according to their schedule. We'll see how it works later in the tutorial.

Zato offers connectors to all the popular technologies and vendors - REST, SQL, Kafka, AMQP, cloud platforms such as Azure, AWS, Microsoft 365 or Google Cloud, and many more - the programming examples have the full list. On the AI side, MCP gateways expose your services as tools for AI agents, and LLM connections let your services invoke OpenAI, Claude, Gemini and compatible models.
The core mental model is simple: a service is a small Python class with a handle method. Services are made available to the outside world through channels - REST, scheduler, MCP and others - and they are hot-deployed to servers without restarts. You'll see all of this in practice in a moment.
Python cloud IDE for API integrations
Before we start, it makes sense to note that you don't need anything besides Zato to go through the tutorial. In particular, Zato ships with its own, browser-based Python IDE that will be used here.
And by the way, you can certainly use VS Code with Zato too, but it's not really needed for the tutorial, so we'll be using the built-in, cloud IDE.

Invoking API services
Now that you have Zato installed, let's invoke a service and see some action.
Go to http://localhost:8183 and, in the top menu, select - the demo service is there, ready to be invoked.
The screen is divided into several parts:
- On the left-hand side, you have your code editor that you use for Python programming
- Parameters that your services expect can be entered in the upper area on the right-hand side
- All the responses from services, whether in JSON or any other format, are returned in the lower area of the right-hand side

Enter "name=Mike Johnson" in the parameters field and click Invoke. This invokes the service on the server and its response appears in the lower area on the right-hand side.
Speaking of input parameters, it's convenient to use "key=value" to invoke your services from the Dashboard, but you can also use JSON on input. For instance, {"name":"Mike Johnson"} means the same as name=Mike Johnson, but it's almost always more convenient to enter key=value parameters, so that's what the tutorial uses.
Clicking "History" will show you a list of all the recent requests and their last responses.

You can also browse your server logs directly from the Dashboard. Press F12 to bring up your browser's developer console and set its logging level to "Info" - this is required because otherwise the browser would show you many completely unrelated messages. With the level set, your server logs are shown in the browser's console, which the rest of the tutorial refers to simply as the console.

Services
A service is a Python class with a handle method, deployed to servers without restarts. Here's the first version of yours.
In the IDE, click , enter api.py as the file name and wait for a confirmation that a new service is ready to be invoked.
You'll note that the default contents of a new file are the same demo code as previously. That's on purpose.
Replace the demo code with this service and click Deploy:
# -*- coding: utf-8 -*-
# Zato
from zato.server.service import Service
# ##############################################################################
class MyService(Service):
""" Returns user details by the person's name.
"""
name = 'api.my-service'
# I/O definition
input = '-name'
output = 'user_type', 'account_no', 'account_balance'
def handle(self):
name = self.request.input.name or 'partner'
self.logger.info(f'cid:{self.cid} Received request for {name}')
self.response.payload.user_type = ''
self.response.payload.account_no = ''
self.response.payload.account_balance = ''
# ##############################################################################
Enter "name=Mike Johnson" and click Invoke. You'll get a response with three empty fields - that's expected, the service doesn't connect to any systems yet.
Whatever you assign to self.response.payload through plain dot access becomes the JSON response, and self.request.input always holds the incoming request. The message building examples show this pattern in full.
The input and output declarations are the validation step. They pin down what may go in and out - anything undeclared is rejected - and, as you'll see in Built-in API Management, they are also what the auto-generated OpenAPI documentation is built from. The minus sign in input = '-name' means the parameter is optional.
Security definitions
External REST clients will need credentials to invoke your service, so let's create them first.
In Dashboard, go to , click Create a new API key, enter the name below and click OK. Then click Change API key next to the new definition, enter the key and click OK again.
Next to each form's values is the same object in YAML, in the format that GitOps exports and imports in one go.
| Field | Value |
|---|---|
| Name | My API Key |
| API key | abc |

What you've just created is a reusable security definition - you can attach it to multiple REST channels, which means that you can secure access to multiple REST endpoints of yours using such definitions. We're not limited to API keys, though - the same goes for Basic Auth or SSL/TLS, for instance.
REST channels
A channel is a definition of an API endpoint. That's how you make your services available to external callers, to apps and systems that want to make use of your services.
In Dashboard, go to , click Create a new REST channel, enter the values below and click OK. The fields to enter new information in are highlighted in yellow, the rest can stay with the default values.
| Field | Value |
|---|---|
| Name | My REST Channel |
| Data format | JSON |
| URL path | /tutorial/api/get-user-details |
| Service | api.my-service |
| Security | API key/My API Key |

But note that an endpoint is not the same as a service, because a single service can be mounted on multiple channels, for instance, each channel with a different security definition or a rate-limiting strategy. You can toggle the options in the form to check what else is possible but we don't need it during the tutorial.
Invoking REST channels

Since a REST channel is just a regular REST endpoint, we can use any REST client to invoke it. Let's use Postman and curl - the result will be the same in either case.
Here are our endpoint's details:
- Address: http://localhost:11223/tutorial/api/get-user-details
- API Key Header: X-API-Key
- API Key Value: abc
- Sample request: {"name":"Mike Johnson"}
- REST method: Any will work because we haven't set any limits in the channel's definition - you can use GET or POST, it doesn't matter - so let's use GET because we're getting data from our endpoint.
$ curl -XGET -H "X-API-Key:abc" -d '{"name":"Mike Johnson"}' http://localhost:11223/tutorial/api/get-user-details
{"account_balance": "", "account_no": "", "user_type": ""}
$
Outgoing REST connections
Your service will orchestrate two remote systems - CRM, which is a REST endpoint, and Billing, which is an SQL database. CRM is where user_type and account_no in the reply come from.
In Zato, connections are independent of your code - you refer to them by name and the platform handles connection pooling, OAuth token refresh and failover, so the same service works across dev, test and production without code changes.
In Dashboard, go to , click Create a new REST outgoing connection, fill out the form as below and click OK.
There are two ways to create outgoing connections:
- Create individually - Click "Create a new REST outgoing connection" to manually configure each connection
- Import from OpenAPI - Click "Import OpenAPI" to bulk-create connections from an OpenAPI/Swagger specification
For this tutorial, we'll create the connection manually.
| Field | Value |
|---|---|
| Name | CRM |
| Data format | JSON |
| Host | https://zato.io |
| URL path | /tutorial/api/get-user |
| Security | No security |

Once the connection is on the list, you can use its Ping link to confirm that the server - not your localhost - can actually reach the remote endpoint.
Outgoing SQL connections
The second system your service will talk to is Billing, and this one is an SQL database. It's where account_balance in the reply comes from. We're using PostgreSQL as the example engine here, but the steps are the same for all the SQL databases that Zato supports.
The same design principle applies as with REST - the connection is configured in Dashboard under a name, your Python code refers to it by that name only, and the platform takes care of the rest, including connection pooling and credentials.
The tutorial's Billing database is available on the Internet and here is the table your service will query - it's called balance and this is its schema:
| Column | Type | Sample value |
|---|---|---|
| id | integer | 1 |
| user_name | text | Mike Johnson |
| account_balance | text | 357.9 |
In Dashboard, go to , click Create a new outgoing SQL connection, fill out the form as below and click OK.
| Field | Value |
|---|---|
| Name | Billing |
| Type | PostgreSQL |
| Host | zato.io |
| Port | 35432 |
| Database name | billing |
| User | tutorial |

Note the port - the form pre-fills it with the engine's default one, so make sure to replace it with 35432, which is the port that the tutorial's database runs on. Everything else, like the pool size, can stay with the default values.
Passwords of new SQL connections are initially set to random values, so the next step is to set the actual one. Click Change password next to the newly created connection, enter "demo" as the password and click OK. With the password in place, the connection's Ping link will confirm that the server can reach the database.
The database user that the tutorial uses is a read-only account, allowed to run SELECT queries against the balance table only, and the database applies rate limiting to incoming queries, which is a good practice for any system exposed to the Internet.
Orchestration
With both connections in place, the service can call CRM and Billing and combine what they return.
Replace the code in api.py with this version and click Deploy:
# -*- coding: utf-8 -*-
# Zato
from zato.server.service import Service
# ##############################################################################
class MyService(Service):
""" Returns user details by the person's name.
"""
name = 'api.my-service'
# I/O definition
input = '-name'
output = 'user_type', 'account_no', 'account_balance'
def handle(self):
name = self.request.input.name or 'partner'
# Get data from CRM ..
crm_conn = self.rest['CRM']
crm_request = {'UserName':name}
crm_data = crm_conn.get(self.cid, crm_request).data
user_type = crm_data['UserType']
account_no = crm_data['AccountNumber']
# .. then query Billing ..
billing_conn = self.out.sql['Billing']
billing_query = 'SELECT account_balance FROM balance WHERE user_name = :name'
billing_data = billing_conn.one(billing_query, {'name': name})
account_balance = billing_data['account_balance']
self.logger.info(f'cid:{self.cid} Returning user details for {name}')
# .. and produce the response.
self.response.payload.user_type = user_type
self.response.payload.account_no = account_no
self.response.payload.account_balance = account_balance
# ##############################################################################
- We refer to the previously created connections by their names, CRM and Billing. We don't hardcode any information about the connections inside the Python code. This promotes reusability because it lets us reconfigure the connection without having to redeploy the service.
- The two systems use different technologies - CRM is a REST endpoint invoked with the GET method while Billing is an SQL database queried with a SELECT statement - yet from the service's perspective both are simply named connections that return data.
- We extract the information from both systems using Python's regular dict notation - a REST response and an SQL row are both dicts to your code. Note that CRM and Billing use different naming conventions, e.g. UserType vs. account_balance.
- The Billing query uses a named parameter (:name) instead of embedding the value in the SQL itself - the database driver handles the substitution securely.
- We return a response to our caller using our own preferred data format, which is "lower_case", e.g. user_type or account_no, even though the source systems were using different naming formats.
Enter "name=Mike Johnson" and click Invoke. All three fields are now populated - your service orchestrates two independent systems and returns a unified response.
At its core, that's how an API service works: accept input, connect to external resources, map data from one format to another and provide a response in your canonical data format. All using simple Python code.
Invoking the complete service
The channel needs no changes - it points to the service by its name, so the same request as before now returns the full reply.

$ curl -XGET -H "X-API-Key:abc" -d '{"name":"Mike Johnson"}' http://localhost:11223/tutorial/api/get-user-details
{"account_balance": "357.9", "account_no": "123456", "user_type": "RGV"}
$
Interestingly, since your browser receives all the server log messages, you can check in the console what your service and the outgoing connections are doing while you're invoking them from Postman.

Here's a brief overview of what you will observe in the logs that your browser is receiving:
- Invocation of the REST channel: Entries indicating the invocation of the REST channel will show details such as the specific channel invoked and the details of the remote API client initiating the request.
- Requests to and responses from CRM and Billing: Following the invocation of the REST channel, you'll see entries corresponding to the REST request sent to CRM and the SQL query sent to the Billing database. These entries can be correlated with the same correlation ID (CID) as the initial REST channel invocation, providing clear traceability of the request flow through the system.
- Custom log messages: Additionally, custom log messages added within the service implementation will also be captured in the log file. These messages can provide additional context or insights into the service's behavior and processing steps.
And there you have it, a reusable API endpoint, implemented in Python and secured with an API key.
Scheduler jobs
Let's now check how to use the scheduler - how to invoke services in the background periodically.
In Dashboard, go to , click Create a new job: interval-based, fill out the form as below and click OK.
| Field | Value |
|---|---|
| Name | my.report.job |
| Job type | Interval-based |
| Active | Yes |
| Service | api.my-service |
| Seconds | 10 |

And you're done. You've just scheduled your service to be invoked in the background once every 10 seconds, indefinitely, using an interval-based job. The console shows each invocation as it happens.
The scheduler supports start dates in a chosen timezone, jitter (to prevent multiple servers from firing at the same instant), max execution time (to kill hung jobs), one-time jobs, and full YAML export for reproducible deployments:
scheduler:
- name: my.report.job
service: api.my-service
job_type: interval_based
seconds: 10
timezone: Europe/Berlin
jitter_ms: 5000
max_execution_time_ms: 15000
For the complete guide, see the Python scheduler tutorial.
Built-in API Management
Here's something you didn't have to do in this tutorial: write API documentation. It already exists.
When you deployed your service and created its REST channel, the platform generated an OpenAPI document for it - from the service's own input and output declarations. The document is published in the OpenAPI console, a browsable portal where API consumers sign in, read the documentation and invoke the endpoints, and everything you'd expect from API Management - publishing, per-caller access, a try-it client - was set up automatically by the steps you have already completed.
Let's see it.
Go to http://localhost:8185/openapi/console - this is the console's address in your environment.
Sign in with the API key you created earlier - the key's name is the username and the key itself is the password:
Field Value Username My API Key Password abc You're in, and the document contains exactly one endpoint -
/tutorial/api/get-user-details, the channel your API key is assigned to. That's the core of the security model: a caller sees only what their credentials can invoke, and the filtering happens on the servers, before anything reaches the browser. Admins, signing in with the same credentials as for the Dashboard, always receive the full view of every documented endpoint in the environment.Note the endpoint's documentation - the
nameinput and theuser_type,account_noandaccount_balanceoutput are taken from the service's I/O definition. You never wrote a spec file, and the document always matches the code because it is generated from what is actually deployed.Now click Try it on the endpoint, enter
{"name":"Mike Johnson"}as the request body and send it. The console invokes the real service with your credentials - the same call you made from Postman earlier, this time from within the documentation.
The document is also plain OpenAPI 3.1, served under the console's address at /openapi/console/openapi.json and /openapi/console/openapi.yaml - paste either URL into Postman or a code generator and authenticate with the same credentials.
That's the whole API Management workload - documentation, publishing and access control - and none of it was a task on your list. Read more in Using the OpenAPI console and, if you run the platform, in OpenAPI administration. The console is one part of a larger whole - the API Management overview shows the full layer, and the API Management documentation collects every chapter, from quota tiers to traffic analytics.
Unit testing

Well, we do have an API service but we don't have any tests for it.
Zato ships with a unit testing framework that lets you test your services without running a server. You write tests using Python's standard unittest module, mock external connections, and verify your service logic works correctly.
GitOps and CI/CD automation
Throughout the tutorial, you may have been wondering about one thing.
OK, we have Python services and unit tests, but how am I actually going to provision my new environments? It's good that there's the Dashboard, but am I supposed to keep clicking and filling out forms each time I have a new environment? If I create a few dozen REST channels and other connections, how do I automate the process of deploying it all? How do I make my builds reproducible?
These are good questions and you've already seen most of the answer - the YAML next to each form above.
There's an entire chapter about it but, in short, everything you do in Dashboard can be exported to YAML, stored in git, and imported elsewhere. The enmasse.yaml file in the zato-main-tutorial repository has every object created in this tutorial in one file, next to the finished api.py.
You push files with such configuration to git and that lets you have reproducible builds - you're always able to reproduce the exact same setup in other systems or environments. This is GitOps - git is the single source of truth for your configuration.
Beyond REST
The REST service you've just built follows the same pattern as every other integration - a service plus a named connection - and the programming examples show that pattern for Kafka, GraphQL, pub/sub, AMQP, the rule engine, rate limiting, HL7v2 and many more.
See also
| Tutorial | What it covers |
|---|---|
| MCP gateways | Python services exposed as tools for AI agents and copilots |
| Deploying Zato projects | New environments and GitOps deployments of code and configuration with Docker |
| Modern REST APIs | Design, status codes, security, OpenAPI and testing of REST APIs in Python |
| HL7 MLLP | HL7 v2 messages routed between clinical systems |
| SOAP services | Requests as Python objects, dot-accessed responses and fault handling |
| Python scheduler | Jobs, tasks and API calls run in the background at sub-minute intervals |
| Microsoft 365 | Microsoft 365 automations with no OAuth2 or Graph API code |
| Rule engine | Business rules in a notation that analysts and developers both read |
Schedule a meaningful demo
Book a demo with an expert who will help you build meaningful systems that match your ambitions
"For me, Zato Source is the only technology partner to help with operational improvements."






- John Adams