Tutorial
Connect to DHIS2, read the metadata of a data set and send a week of values to it from a Python service.
This tutorial connects Zato to the public DHIS2 demo instance, reads the metadata of a weekly data set and sends one week of values to it, first as a dry run and then for real. The same steps apply to any DHIS2 instance, with its own address and token.
How Zato connects to DHIS2
DHIS2 is reached through its Web API, a REST API under /api. Zato calls it through REST outgoing connections, one per endpoint. The integration logic lives in services, which are Python classes deployed to Zato, and a service invokes a connection by its name.
DHIS2 keeps two kinds of data:
- Aggregate data - numbers per data element, organisation unit and period, such as the weekly count of malaria cases at a health facility. Data elements are grouped in data sets, and values are written to
/api/dataValueSets. - Case data - records of individual people or events, kept in tracker and event programs and written to
/api/tracker.
Every metadata object, such as a data set, a data element or an organisation unit, has an 11-character UID and can also have a code. Requests refer to objects by either, and this tutorial uses both.
The demo instance runs DHIS2 2.43 at https://play.im.dhis2.org/stable-2-43-1/ and is reset every night. The tutorial needs a personal access token of a user of that instance.
Step 1 - The security definition
DHIS2 authenticates API clients with personal access tokens, sent in the Authorization header with the ApiToken prefix. A bearer token definition in its static form sends it with every request.
- In the Dashboard, open Security ▹ Bearer tokens.
- Click Create a Bearer token definition and select the Static tokens tab.
- Enter
DHIS2 Demoas the Name. - Leave
Authorizationas the Header. - Enter
ApiTokenas the Prefix. - Paste the personal access token into Token.
- Click OK.

Step 2 - The connections
Each DHIS2 endpoint the tutorial calls has a REST outgoing connection of its own. Create them under Connections ▹ Outgoing ▹ REST, each with https://play.im.dhis2.org as the Host, DHIS2 Demo as the Security and JSON as the Data format:
| Name | URL path |
|---|---|
DHIS2 System Info | /stable-2-43-1/api/system/info |
DHIS2 Data Set | /stable-2-43-1/api/dataSets/{id} |
DHIS2 Org Units | /stable-2-43-1/api/organisationUnits |
DHIS2 Data Values | /stable-2-43-1/api/dataValueSets |
{id} is a placeholder that each call fills in, as Calling REST APIs describes.
Click Ping in the row of DHIS2 System Info to confirm that Zato reaches the instance.
Step 3 - The configuration file
The services read the identifiers of the objects they work with from a configuration file rather than from their code. Create config/user-conf/dhis2_tutorial.ini in your project:
# config/user-conf/dhis2_tutorial.ini
[data_set]
id = Nyh6laLdBEJ
[org_unit]
code = OU_559
[element]
malaria = vq2qO3eTrNi
cholera = UsSUX0cpKsH
Nyh6laLdBEJ is the IDSR Weekly data set of the demo instance, OU_559 is the code of the Ngelehun CHC health facility, and the two data elements are the weekly malaria and cholera counts of that data set.
Step 4 - Read the metadata
The first service looks up the organisation unit by its code and reads the data set with the names of its data elements. Deploy it and invoke it from the Dashboard's IDE under Services ▹ IDE:
# -*- coding: utf-8 -*-
# Zato
from zato.server.service import Service
class ReadMetadata(Service):
name = 'dhis2.tutorial.read-metadata'
def read_org_unit(self) -> 'dict':
# Find the organisation unit by its code ..
code = self.config.dhis2_tutorial.org_unit.code
params = {'filter': f'code:eq:{code}', 'fields': 'id,name'}
conn = self.rest['DHIS2 Org Units']
response = conn.get(params=params)
# .. which is unique, so there is exactly one match.
org_units = response.data['organisationUnits']
org_unit = org_units[0]
return org_unit
params fills in placeholders in the URL path and sends everything else as query parameters. fields selects the attributes DHIS2 returns, and a nested selection such as dataElement[id,name] reaches into related objects:
def read_data_set(self) -> 'dict':
data_set_id = self.config.dhis2_tutorial.data_set.id
fields = 'name,periodType,dataSetElements[dataElement[id,name]]'
params = {'id': data_set_id, 'fields': fields}
conn = self.rest['DHIS2 Data Set']
response = conn.get(params=params)
data_set = response.data
return data_set
def handle(self) -> 'None':
org_unit = self.read_org_unit()
data_set = self.read_data_set()
# Each data set element wraps one data element ..
data_elements = []
for item in data_set['dataSetElements']:
data_element = item['dataElement']
data_elements.append(data_element)
# .. which is what the response lists.
self.response.payload = {
'org_unit': org_unit,
'data_set': data_set['name'],
'period_type': data_set['periodType'],
'data_elements': data_elements,
}
The response shows what the data set expects - weekly values for five data elements, each with the UID it is written under:
{
"org_unit": {"id": "DiszpKrYNg8", "name": "Ngelehun CHC"},
"data_set": "IDSR Weekly",
"period_type": "Weekly",
"data_elements": [
{"id": "HS9zqaBdOQ4", "name": "IDSR Plague"},
{"id": "vq2qO3eTrNi", "name": "IDSR Malaria"},
{"id": "YazgqXbizv1", "name": "IDSR Measles"},
{"id": "UsSUX0cpKsH", "name": "IDSR Cholera"},
{"id": "noIzB569hTM", "name": "IDSR Yellow fever"}
]
}
Step 5 - Check the values with a dry run
The second service sends a week of malaria and cholera counts. A data value set names the data set, the period and the organisation unit once, and lists the values under them:
# -*- coding: utf-8 -*-
# Zato
from zato.server.service import Service
class SendWeeklyReport(Service):
name = 'dhis2.tutorial.send-weekly-report'
input = 'period', 'malaria', 'cholera'
def build_payload(self) -> 'dict':
request = self.request.input
tutorial = self.config.dhis2_tutorial
element = tutorial.element
# One value per data element ..
data_values = [
{'dataElement': element.malaria, 'value': request.malaria},
{'dataElement': element.cholera, 'value': request.cholera},
]
# .. for one organisation unit and one week.
payload = {
'dataSet': tutorial.data_set.id,
'period': request.period,
'orgUnit': tutorial.org_unit.code,
'dataValues': data_values,
}
return payload
The organisation unit is given by its code and the data elements by their UIDs, so orgUnitIdScheme=code tells DHIS2 how to read the first. dryRun=true makes DHIS2 validate the values and return the import summary without storing anything:
def handle(self) -> 'None':
payload = self.build_payload()
params = {'orgUnitIdScheme': 'code', 'dryRun': 'true'}
# Send the values ..
conn = self.rest['DHIS2 Data Values']
response = conn.post(payload, params)
# .. log each value DHIS2 would not accept ..
import_summary = response.data['response']
for conflict in import_summary['conflicts']:
self.logger.warning('DHIS2 conflict: %s', conflict['value'])
# .. and return the counts of what it would do with the rest.
self.response.payload = {
'status': import_summary['status'],
'import_count': import_summary['importCount'],
}
Invoke it with a valid malaria count and a cholera count that is not a whole number. 2026W40 is the DHIS2 identifier of ISO week 40 of 2026. DHIS2 takes every value as a string, numbers included, and rejects the whole request when a value is a JSON number:
The data element only accepts zero or positive integers, so DHIS2 ignores that value and reports it as a conflict:
The server log carries the reason, which reads Value #1 value `1.5` is no valid INTEGER_ZERO_OR_POSITIVE. #1 is the position of the value in dataValues, counted from zero. A data element that does not belong to the data set makes DHIS2 reject the whole set instead, with the status ERROR and HTTP 409.
Step 6 - Send the values
Remove 'dryRun': 'true' from params, deploy the service again and invoke it with valid counts:
The status is SUCCESS and both values are counted under imported. Sending the same period again replaces the stored values and counts them under updated, so a corrected report is the same call with new numbers.
The values are visible in the DHIS2 Data Entry app, under Ngelehun CHC, IDSR Weekly and week 40 of 2026.
Aggregate vs case data
Every flow that writes to DHIS2 starts with the choice this tutorial made without stating it. A value that answers "how many" for a place and a period is aggregate data and goes to a data set through /api/dataValueSets, as above. A record that describes one patient, one sample or one event - with a date, a place and attributes of its own - is case data and goes to a tracker or event program through /api/tracker.
The two are often the same information at different levels. The lab results integration writes each result to its case and also sends the weekly count of results to a data set. DHIS2 can compute aggregate figures from case data itself through program indicators, so a service sends counts to a data set when the data set is what the reporting chain reads, and when the cases themselves are not in DHIS2.
See also
| Page | What it covers |
|---|---|
| Security | Tokens, passwords, event hooks, the Route API and case data in the audit log |
| Lab results | Laboratory results written to tracker cases from HL7 v2 and FHIR |
| Calling REST APIs | Query parameters, payloads and response objects |