URI and API Automation¶
ansible.builtin.uri sends an HTTP or HTTPS request (GET, POST, PUT, PATCH, DELETE) from Ansible and returns the response as structured data: result.status for the status code, result.json for a parsed JSON body. It fails the task if the status code isn't in status_code, which defaults to [200] only. It's Ansible's built-in replacement for curl.
What You'll Learn¶
- Calling an HTTP API declaratively with
ansible.builtin.uri - The parameters you'll use on almost every request, and their defaults
- How to read a JSON response, authenticate, and retry until a service is ready
- How to translate a
curlcommand intouri, and whyuribeatsshell: curl
Minimal Example¶
- name: Check that the app is healthy
ansible.builtin.uri:
url: "http://localhost:8080/health"
status_code: 200
uri runs on the managed node, like every other module. localhost here is the target host, not your control node. To call an external API once from the control node, add delegate_to: localhost and run_once: true.
Parameters You'll Actually Use¶
| Parameter | Default | What it does |
|---|---|---|
url |
(required) | Full URL, including http:// or https:// |
method |
GET |
GET, POST, PUT, PATCH, DELETE, HEAD, or any other verb |
headers |
{} |
Dictionary of request headers |
body |
none | Request body. A dictionary or list when used with body_format: json or form-urlencoded |
body_format |
raw |
json, form-urlencoded, form-multipart, or raw. json serializes body and sets Content-Type: application/json |
status_code |
[200] |
Status codes that count as success. Anything else fails the task |
return_content |
false |
Put the response body in result.content. JSON responses are parsed into result.json either way |
url_username / url_password |
none | Credentials for HTTP basic auth |
force_basic_auth |
false |
Send credentials on the first request instead of waiting for a 401 challenge |
validate_certs |
true |
Verify the server's TLS certificate. Set ca_path instead of turning this off |
timeout |
30 |
Seconds to wait for a response |
follow_redirects |
safe |
safe follows redirects only for GET and HEAD. all follows every redirect |
dest |
none | Save the response body to this path on the managed node |
GET a JSON API and Use the Response¶
- name: Look up the latest node_exporter release
ansible.builtin.uri:
url: https://api.github.com/repos/prometheus/node_exporter/releases/latest
headers:
Accept: application/vnd.github+json
register: release
delegate_to: localhost
run_once: true
- name: Show the version
ansible.builtin.debug:
msg: "Latest node_exporter is {{ release.json.tag_name }}"
Because the response is application/json, uri parses it into release.json. You can index into it like any other variable: release.json.assets | map(attribute='name') | list.
POST With a JSON Body and a Bearer Token¶
- name: Register this host with the service registry
ansible.builtin.uri:
url: "https://registry.internal/api/v1/hosts"
method: POST
headers:
Authorization: "Bearer {{ registry_token }}"
body_format: json
body:
hostname: "{{ inventory_hostname }}"
role: web
status_code: [200, 201]
register: registration
changed_when: registration.status == 201
no_log: true
body_format: jsonserializesbody:to JSON and sets the rightContent-Typeautomatically.status_code: [200, 201]matters. With the default[200], an API that answers a POST with201 Createdor204 No Contentfails the task even though the request worked.urican't tell whether a POST changed anything on the server, sochanged_whenreports it from the status:201means a new record,200means it already existed.no_log: truebecause the request includes a bearer token. See Security.
Basic Auth and OAuth Tokens¶
- name: Call an API that uses HTTP basic auth
ansible.builtin.uri:
url: https://jenkins.internal/api/json
url_username: "{{ jenkins_user }}"
url_password: "{{ jenkins_api_token }}"
force_basic_auth: true
register: jenkins
no_log: true
Without force_basic_auth: true, Ansible sends credentials only after the server replies 401 with a WWW-Authenticate header. Many APIs, including GitHub and Jenkins, never send that challenge, so the request fails with 401 even though the credentials are correct.
For OAuth2 client credentials, fetch a token with a form-encoded POST, then pass it to later requests:
- name: Get an access token
ansible.builtin.uri:
url: https://auth.example.com/oauth2/token
method: POST
body_format: form-urlencoded
body:
grant_type: client_credentials
client_id: "{{ client_id }}"
client_secret: "{{ client_secret }}"
register: token
no_log: true
- name: Call the API with the token
ansible.builtin.uri:
url: https://api.example.com/v1/inventory
headers:
Authorization: "Bearer {{ token.json.access_token }}"
register: inventory
no_log: true
Retry Until a Service Is Ready¶
- name: Wait for the app to report healthy after a restart
ansible.builtin.uri:
url: "http://{{ ansible_host }}:8080/health"
status_code: 200
register: health
until: health.status == 200
retries: 30
delay: 5
This polls every 5 seconds for up to 150 seconds. While the port is closed, health.status is -1, so the loop keeps retrying instead of failing on the first attempt. This is the standard health gate in a rolling deployment.
The Response: What register Gives You¶
| Key | Contains |
|---|---|
status |
HTTP status code, or -1 if the connection failed |
json |
The parsed body, when the response is JSON |
content |
The raw body, only with return_content: true |
msg |
A summary such as OK (512 bytes) or the failure reason |
url |
The final URL after redirects |
redirected |
Whether a redirect was followed |
elapsed |
Seconds the request took |
cookies |
Cookies the server set, as a dictionary |
| response headers | Each header as its own key, lowercased with - changed to _, such as content_type or location |
Run ansible.builtin.debug: var=result once against a new API to see the exact shape.
curl to uri¶
| curl | uri |
|---|---|
curl -X POST |
method: POST |
-H 'Accept: application/json' |
headers: { Accept: application/json } |
-d '{"a":1}' with a JSON header |
body: { a: 1 } and body_format: json |
--data-urlencode a=1 |
body: { a: 1 } and body_format: form-urlencoded |
-u user:pass |
url_username, url_password, force_basic_auth: true |
-k / --insecure |
validate_certs: false (labs only) |
--cacert ca.pem |
ca_path: /path/to/ca.pem |
--cert / --key |
client_cert, client_key |
-L |
follow_redirects: all |
-o file |
dest: /path/to/file |
--max-time 10 |
timeout: 10 |
--fail |
status_code (fails on anything not listed) |
To download a file, prefer ansible.builtin.get_url. It verifies the file with checksum:, sets owner, group, and mode in the same task, and is the module reviewers expect for downloads.
Why Not shell: curl ...¶
# Loses structure: raw exit code, unparsed stdout, no built-in status check
- ansible.builtin.shell: curl -X POST https://registry.internal/api/v1/hosts -d '{"hostname":"{{ inventory_hostname }}"}'
uri gives you a structured, register-able result (registration.json, registration.status) instead of a string you'd have to parse yourself, checks the status code declaratively via status_code:, and avoids building a shell command string out of variables — which is a real injection risk the moment any value is even slightly untrusted. It also works on hosts that don't have curl installed.
Common Errors¶
| Error | Cause and fix |
|---|---|
Status code was 201 and not [200] |
The request worked, but 201 isn't in status_code. Add it: status_code: [200, 201] |
Status code was 401 and not [200]: HTTP Error 401: Unauthorized |
Bad credentials, or basic auth without force_basic_auth: true |
Status code was -1 and not [200]: Request failed: <urlopen error [Errno 111] Connection refused> |
Nothing is listening. Either the service is down, or uri ran on the managed node when you meant the control node (add delegate_to: localhost) |
Status code was -1 ... CERTIFICATE_VERIFY_FAILED |
The server uses a private CA. Point ca_path at the CA bundle rather than setting validate_certs: false |
The read operation timed out |
The API took longer than timeout (default 30 seconds). Raise timeout |
'dict object' has no attribute 'json' |
The response wasn't JSON, often an HTML error page. Add return_content: true and check result.content |
Common Mistakes¶
- Leaving
status_codeat its default of[200]for a POST, PUT, or DELETE that returns201,202, or204. The task fails even though the request worked. - Not setting
no_log: trueon requests carrying credentials or tokens. - Calling an external API from every host when it should run once: add
run_once: trueanddelegate_to: localhost. - Turning off
validate_certsinstead of trusting the right CA withca_path. - Reaching for
shell: curlout of habit whenurialready covers the case — see Command vs. Shell.
Interview Questions¶
- What does
uri'sstatus_code:parameter actually control, and what's its default? - Where does a
uritask run, and how do you make it call an API from the control node instead? - Why does basic auth sometimes fail with
401even with correct credentials? - Why is
uripreferred overshell: curlfor API automation in a reviewed playbook?
Related¶
Next¶
Continue to Playbook Engineering.